diff --git a/Podfile b/Podfile index 373a26f8c..ae568f035 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! @@ -8,7 +8,12 @@ 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/oxen-io/session-ios-swift-sodium.git', branch: 'session-build' + 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' pod 'WebRTC-lib' pod 'SocketRocket', '~> 0.5.1' @@ -18,14 +23,14 @@ abstract_target 'GlobalDependencies' do pod 'Reachability' 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 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage' pod 'ZXingObjC' + pod 'DifferenceKit' end # Dependencies to be included only in all extensions/frameworks abstract_target 'FrameworkAndExtensionDependencies' do - pod 'Curve25519Kit', git: 'https://github.com/signalapp/Curve25519Kit.git' + 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' @@ -35,10 +40,10 @@ 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' + pod 'DifferenceKit' end target 'SignalUtilitiesKit' do @@ -46,17 +51,34 @@ 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 target 'SessionMessagingKit' do pod 'Reachability' pod 'SAMKeychain' pod 'SwiftProtobuf', '~> 1.5.0' + pod 'DifferenceKit' + + target 'SessionMessagingKitTests' do + inherit! :complete + + pod 'Quick' + pod 'Nimble' + end end target 'SessionUtilitiesKit' do pod 'SAMKeychain' + pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage' + + target 'SessionUtilitiesKitTests' do + inherit! :complete + + pod 'Quick' + pod 'Nimble' + end end end end @@ -69,6 +91,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) @@ -85,7 +108,17 @@ 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 + +# 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 eae808253..37f4ac9c5 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -21,9 +21,24 @@ PODS: - Curve25519Kit (2.1.0): - CocoaLumberjack - SignalCoreKit - - Mantle (2.1.0): - - Mantle/extobjc (= 2.1.0) - - Mantle/extobjc (2.1.0) + - 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.26.0): + - 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) - NVActivityIndicatorView/Base (5.1.1) @@ -38,6 +53,7 @@ PODS: - PromiseKit/UIKit (6.15.3): - PromiseKit/CorePromise - PureLayout (3.1.9) + - Quick (5.0.1) - Reachability (3.2) - SAMKeychain (1.5.3) - SignalCoreKit (1.0.0): @@ -114,9 +130,10 @@ 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 + - YYImage/Core - ZXingObjC (3.6.5): - ZXingObjC/All (= 3.6.5) - ZXingObjC/All (3.6.5) @@ -124,20 +141,24 @@ PODS: DEPENDENCIES: - AFNetworking - CryptoSwift - - Curve25519Kit (from `https://github.com/signalapp/Curve25519Kit.git`) - - Mantle (from `https://github.com/signalapp/Mantle`, branch `signal-master`) + - Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`) + - DifferenceKit + - GRDB.swift/SQLCipher + - Nimble - NVActivityIndicatorView - PromiseKit - PureLayout (~> 3.1.8) + - Quick - Reachability - SAMKeychain - SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`) - SocketRocket (~> 0.5.1) - - Sodium (~> 0.9.1) + - Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`) + - SQLCipher (~> 4.0) - 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 SPEC REPOS: @@ -145,14 +166,18 @@ SPEC REPOS: - AFNetworking - CocoaLumberjack - CryptoSwift + - DifferenceKit + - GRDB.swift + - libwebp + - Nimble - NVActivityIndicatorView - OpenSSL-Universal - PromiseKit - PureLayout + - Quick - Reachability - SAMKeychain - SocketRocket - - Sodium - SQLCipher - SwiftProtobuf - WebRTC-lib @@ -160,13 +185,14 @@ SPEC REPOS: EXTERNAL SOURCES: Curve25519Kit: - :git: https://github.com/signalapp/Curve25519Kit.git - Mantle: - :branch: signal-master - :git: https://github.com/signalapp/Mantle + :branch: session-version + :git: https://github.com/oxen-io/session-ios-curve-25519-kit.git SignalCoreKit: :branch: session-version :git: https://github.com/oxen-io/session-ios-core-kit + Sodium: + :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 @@ -175,14 +201,14 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: Curve25519Kit: - :commit: 4fc1c10e98fff2534b5379a9bb587430fdb8e577 - :git: https://github.com/signalapp/Curve25519Kit.git - Mantle: - :commit: e7e46253bb01ce39525d90aa69ed9e85e758bfc4 - :git: https://github.com/signalapp/Mantle + :commit: b79c2ace600bfd3784e9c33cf1f254b121312edc + :git: https://github.com/oxen-io/session-ios-curve-25519-kit.git SignalCoreKit: :commit: 4590c2737a2b5dc0ef4ace9f9019b581caccc1de :git: https://github.com/oxen-io/session-ios-core-kit + Sodium: + :commit: 4ecfe2ddfd75e7b396c57975b4163e5c8cf4d5cc + :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 @@ -195,16 +221,20 @@ SPEC CHECKSUMS: CocoaLumberjack: 543c79c114dadc3b1aba95641d8738b06b05b646 CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 - Mantle: 2fa750afa478cd625a94230fbf1c13462f29395b + DifferenceKit: 5659c430bb7fe45876fa32ce5cba5d6167f0c805 + GRDB.swift: 1395cb3556df6b16ed69dfc74c3886abc75d2825 + libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc + Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5 PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88 + Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179 Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531 - Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da + Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae SQLCipher: 98dc22f27c0b1790d39e710d440f22a466ebdb59 SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2 WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb @@ -212,6 +242,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: a3d89a6cc8735285fd51348ca05cea71f2c28872 +PODFILE CHECKSUM: f0857369c4831b2e5c1946345e76e493f3286805 -COCOAPODS: 1.11.2 +COCOAPODS: 1.11.3 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 2caf35997..ed849e696 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -7,33 +7,29 @@ objects = { /* Begin PBXBuildFile section */ - 10AC6C7D50A0C865C5E4779B /* Pods_SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 71CFEDD2D3C54277731012DF /* Pods_SessionUIKit.framework */; }; - 26E5526A63EE57E6252B6E3F /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E8B3E83E635D96DC8F4EFD9E /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; + 1FFD68A448D5A1439F2F02FD /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBA125424EDD2417B515C63A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */; }; + 3289CA2E9E89DA9D4D52A90C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0BF4561630A52BE96F164CF6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */; }; 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */; }; 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 */; }; 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 */; }; 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 */; }; 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 */; }; 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 */; }; @@ -45,8 +41,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 */; }; - 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 */; }; @@ -55,11 +49,9 @@ 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 */; }; - 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,11 +93,9 @@ 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 */; }; - 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 */; }; @@ -115,65 +105,52 @@ 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 */; }; - 58DC2E64CCCE074490C984EB /* Pods_GlobalDependencies_Session.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 88A804919A55752B13ACE3A5 /* Pods_GlobalDependencies_Session.framework */; }; - 65BF14F694829B7A7F38C806 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEEF92E2CA5FAADF72A46E13 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */; }; + 5163CBC4F53274C88D1F88F8 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 782B65234A707D762FEAFD3B /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */; }; + 63A15A5E6605581543B4A5B4 /* Pods_SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DEB8C073F5E3C418BFB96C3 /* Pods_SessionUIKit.framework */; }; 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; }; + 71B1D8AF3ADE2BD191256496 /* Pods_GlobalDependencies_Session.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 48AD214D67ABED845101E795 /* Pods_GlobalDependencies_Session.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 */; }; 7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0EFDED274F598600FFAAE7 /* TimestampUtils.swift */; }; 7B0EFDF0275084AA00FFAAE7 /* CallMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0EFDEF275084AA00FFAAE7 /* CallMessageCell.swift */; }; - 7B0EFDF2275449AA00FFAAE7 /* TSInfoMessage+Calls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0EFDF1275449AA00FFAAE7 /* TSInfoMessage+Calls.swift */; }; 7B0EFDF4275490EA00FFAAE7 /* ringing.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 7B0EFDF3275490EA00FFAAE7 /* ringing.mp3 */; }; 7B0EFDF62755CC5400FFAAE7 /* CallMissedTipsModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B0EFDF52755CC5400FFAAE7 /* CallMissedTipsModal.swift */; }; 7B13E1E92810F01300BD4F64 /* SessionCallManager+Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B13E1E82810F01300BD4F64 /* SessionCallManager+Action.swift */; }; 7B13E1EB2811138200BD4F64 /* PrivacySettingsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B13E1EA2811138200BD4F64 /* PrivacySettingsTableViewController.swift */; }; - 7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E1271E743B00848B49 /* OWSSounds.swift */; }; 7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E3271FC59C00848B49 /* CallModal.swift */; }; 7B1581E6271FD2A100848B49 /* VideoPreviewVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */; }; 7B1581E827210ECC00848B49 /* RenderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E727210ECC00848B49 /* RenderView.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 */; }; - 7B251C3627D82D9E001A6284 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; }; 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; }; 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; }; - 7B703747283CA919000DCF35 /* Storage+Calls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B703746283CA919000DCF35 /* Storage+Calls.swift */; }; 7B7CB189270430D20079FF93 /* CallMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB188270430D20079FF93 /* CallMessageView.swift */; }; 7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */; }; 7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */; }; 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 */; }; - 7B93D07327CF19C800811CB6 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07227CF19C800811CB6 /* MessageRequestsMigration.swift */; }; 7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */; }; 7BA68909272A27BE00EFC32F /* SessionCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA68908272A27BE00EFC32F /* SessionCall.swift */; }; 7BA6890D27325CCC00EFC32F /* SessionCallManager+CXCallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA6890C27325CCC00EFC32F /* SessionCallManager+CXCallController.swift */; }; 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 */; }; 7BAF54D427ACCF01003D12F8 /* SAEScreenLockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D227ACCF01003D12F8 /* SAEScreenLockViewController.swift */; }; 7BAF54D827ACD0E3003D12F8 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */; }; - 7BAF54D927ACD0E3003D12F8 /* String+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D627ACD0E3003D12F8 /* String+Localization.swift */; }; - 7BAF54DA27ACD0E3003D12F8 /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54D727ACD0E3003D12F8 /* UITableView+ReusableView.swift */; }; 7BAF54DC27ACD12B003D12F8 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.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, ); }; }; 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 */; }; - 7BD477AC27F15F41004E2822 /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD477AB27F15F41004E2822 /* OpenGroupServerIdLookupMigration.swift */; }; - 7BD477AE27F526E3004E2822 /* BlockingManagerRemovalMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD477AD27F526E3004E2822 /* BlockingManagerRemovalMigration.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 */; }; @@ -181,18 +158,16 @@ 7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFD1A8B2747150E00FB91B9 /* TurnServerInfo.swift */; }; 7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */ = {isa = PBXBuildFile; fileRef = 7BFD1A962747689000FB91B9 /* Session-Turn-Server */; }; 7BFFB33C27D02F5800BEA04E /* CallPermissionRequestModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFFB33B27D02F5800BEA04E /* CallPermissionRequestModal.swift */; }; - 96EB4427CAAFFFC92E52573C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E77FE0A560DE43C5741FB252 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework */; }; + 92EB2776D36B22D2E0552A05 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2691123A7F231EDD8226C4B5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework */; }; + 98547545DAF8E7916DF9F0BF /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F84A214B9A1C0CCF6DB09C8 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; - A13FC3642BE5D37A004D0EC8 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 19942D14DEF67D588752ADB2 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */; }; 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 */; }; - B3CDA9D910FEC0BE298B7243 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65CC2A54604AED4D817AEAD7 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */; }; + A49760F37A9AE09D57ECE415 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5442DF945D862CEDF7F8AC49 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.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, ); }; }; - B6BC1D4DFFDA548179F75EC6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4014869E7FA81AE97FD43B4 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework */; }; B6F509971AA53F760068F56A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6F509951AA53F760068F56A /* Localizable.strings */; }; B6FE7EB71ADD62FA00A6D22F /* PushKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6FE7EB61ADD62FA00A6D22F /* PushKit.framework */; }; B8041A9525C8FA1D003C2166 /* MediaLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8041A9425C8FA1D003C2166 /* MediaLoaderView.swift */; }; @@ -208,11 +183,9 @@ 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 */; }; - 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 */; }; @@ -230,12 +203,8 @@ 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 */; }; - 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 */; }; @@ -243,20 +212,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 /* 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 */; }; 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, ); }; }; 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, ); }; }; @@ -265,25 +226,14 @@ 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, ); }; }; - 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 */; }; 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 */; }; 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 */; }; - B8B3204E258C15C80020074B /* ContactsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B32044258C117C0020074B /* ContactsMigration.swift */; }; B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B320B6258C30D70020074B /* HTMLMetadata.swift */; }; B8B558F126C4BB0600693325 /* CameraManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B558F026C4BB0600693325 /* CameraManager.swift */; }; B8B558FF26C4E05E00693325 /* WebRTCSession+MessageHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8B558FE26C4E05E00693325 /* WebRTCSession+MessageHandling.swift */; }; @@ -291,8 +241,6 @@ B8BC00C0257D90E30032E807 /* General.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC00BF257D90E30032E807 /* General.swift */; }; B8BF43BA26CC95FB007828D1 /* WebRTC+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BF43B926CC95FB007828D1 /* WebRTC+Utilities.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 */; }; @@ -313,10 +261,8 @@ 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 */; }; - 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 */; }; @@ -324,24 +270,15 @@ 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 */; }; - C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; }; - C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5BC2554B00D00555489 /* ReadReceipt.swift */; }; + C2CAA4A9737D865B34560B8C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6737124ECBC2DFEE2DD716D3 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework */; }; 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 */; }; - 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 */; }; - 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 */; }; @@ -349,104 +286,24 @@ 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 */; }; - 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 */; }; - 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 */; }; - 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 */; }; - 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 */; }; - 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 */; }; + 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, ); }; }; - 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 */; }; - 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, ); }; }; - 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, ); }; }; - 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, ); }; }; - 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 */; }; - 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 */; }; 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 */; }; - 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 */; }; - 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 */; }; 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 */; }; @@ -473,91 +330,60 @@ 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, ); }; }; 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 */; }; - 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 */; }; - 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 */; }; 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 */; }; 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, ); }; }; 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, ); }; }; 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, ); }; }; - 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 */; }; 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 */; }; - C352A2F525574B4700338F3E /* Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2F425574B4700338F3E /* Job.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 */; }; - 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, ); }; }; - 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 */; }; 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 */; }; 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 */; }; @@ -574,33 +400,15 @@ 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, ); }; }; - 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 */; }; 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 */; }; @@ -617,7 +425,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 */; }; @@ -628,7 +435,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 */; }; @@ -645,10 +451,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 */; }; - 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 */; }; @@ -661,11 +463,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 */; }; @@ -673,22 +472,7 @@ 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 */; }; - 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 */; }; - 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 */; }; @@ -697,22 +481,18 @@ 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 */; }; - 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 */; }; - C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */; }; - C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */; }; + C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */; }; + C3BBE0B52554F0E10050F1E3 /* (null) in Sources */ = {isa = PBXBuildFile; }; 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, ); }; }; 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 */; }; @@ -731,11 +511,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 */; }; - 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 */; }; 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 */; }; @@ -753,17 +529,9 @@ 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 */; }; + C3D9E3BE25676AD70040E4F3 /* (null) in Sources */ = {isa = PBXBuildFile; }; + C3D9E3BF25676AD70040E4F3 /* (null) in Sources */ = {isa = PBXBuildFile; }; 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 */; }; @@ -771,28 +539,275 @@ 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 */; }; - 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 */; }; + 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 */; }; 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 */; }; EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */ = {isa = PBXBuildFile; fileRef = EF764C341DB67CC5000D9A87 /* UIViewController+Permissions.m */; }; FC3BD9881A30A790005B96BB /* Social.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3BD9871A30A790005B96BB /* Social.framework */; }; 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 /* SMKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacy.swift */; }; + FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.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 */; }; + 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 */; }; + FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B727F51ECA00122BE0 /* Migration.swift */; }; + FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7B927F51F2100122BE0 /* TargetMigrations.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 */; }; + 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 /* SnodeAPIEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7D127F5797A00122BE0 /* SnodeAPIEndpoint.swift */; }; + FD17D7D427F6584600122BE0 /* OnionRequestAPIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7D327F6584600122BE0 /* OnionRequestAPIError.swift */; }; + FD17D7D827F658E200122BE0 /* OnionRequestAPIDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7D727F658E200122BE0 /* OnionRequestAPIDestination.swift */; }; + 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 /* 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 */; }; + FD3C906227E411AF00CD579F /* HeaderSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906127E411AF00CD579F /* HeaderSpec.swift */; }; + FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C906327E4122F00CD579F /* RequestSpec.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 */; }; + FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.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 */; }; + FD848B9A28442CE6000E298B /* StorageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9928442CE6000E298B /* StorageError.swift */; }; + FD848B9C284435D7000E298B /* AppSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9B284435D7000E298B /* AppSetup.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 */; }; + FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; }; + 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 */; }; + 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 */; }; + 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 */; }; + 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 */; }; + FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.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 */; }; + 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 */; }; + FDC4385D27B4C18900C60D73 /* Room.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385C27B4C18900C60D73 /* Room.swift */; }; + FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4385E27B4C4A200C60D73 /* PinnedMessage.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 */; }; + FDC4386B27B4E88F00C60D73 /* BatchRequestInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */; }; + 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 */; }; + 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 */; }; + 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 */; }; + FDC438B127BB159600C60D73 /* RequestInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438B027BB159600C60D73 /* RequestInfo.swift */; }; + 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 /* 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; }; + FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8F02817ABE600352A0C /* Optional+Utilities.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 */; }; + 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 */; }; + FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.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 */; }; + FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */; }; + FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsProtocol.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 */; }; + 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 */; }; + 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 */; }; + 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 */ @@ -915,6 +930,34 @@ remoteGlobalIDString = C33FD9AA255A548A00E217F9; remoteInfo = SignalUtilitiesKit; }; + FD83B9B427CF200A005E1583 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + remoteGlobalIDString = C3C2A678255388CC00C340D1; + remoteInfo = SessionUtilitiesKit; + }; + FDC4389327B9FFC700C60D73 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D221A080169C9E5E00537ABF /* Project object */; + proxyType = 1; + 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 */ @@ -948,20 +991,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0403B6AF17DFAD629A3AF862 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig"; sourceTree = ""; }; - 08EF72D6EB5CDC49C863781E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig"; sourceTree = ""; }; - 0F1A8805563934A3324676D1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.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 = ""; }; - 1758FCA85C98360EBA3949CF /* 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 = ""; }; - 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 = ""; }; - 19942D14DEF67D588752ADB2 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 1C93CF3971B64E8B6C1F9AC1 /* Pods-SignalShareExtension.test.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.test.xcconfig"; path = "Pods/Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.test.xcconfig"; sourceTree = ""; }; - 1CE3CD5C23334683BDD3D78C /* Pods-Signal.test.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Signal.test.xcconfig"; path = "Pods/Target Support Files/Pods-Signal/Pods-Signal.test.xcconfig"; sourceTree = ""; }; - 2183DCA28E0620BC73FCC554 /* Pods_SessionProtocolKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SessionProtocolKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 264033E641846B67E0CB21B0 /* Pods-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUtilitiesKit/Pods-SessionUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; - 264242150E87D10A357DB07B /* Pods_SignalMessaging.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalMessaging.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 292F8FB4C82D8FF94571D837 /* 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 = ""; }; - 3303495F6651CE2F3CC9693B /* Pods-SessionUtilities.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUtilities.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUtilities/Pods-SessionUtilities.app store release.xcconfig"; sourceTree = ""; }; + 0BF4561630A52BE96F164CF6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E836037CC97CE5A47735596 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig"; sourceTree = ""; }; + 1A0882BF820F5B44969F91F1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + 245BF74EF6348E2D4125033F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig"; sourceTree = ""; }; + 2581AFACDDDC1404866D7B8C /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; + 2691123A7F231EDD8226C4B5 /* 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; }; + 29CF8C79F41BF00B1C2E59A0 /* Pods-SessionUIKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUIKit.app store release.xcconfig"; path = "Target Support Files/Pods-SessionUIKit/Pods-SessionUIKit.app store release.xcconfig"; sourceTree = ""; }; 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 = ""; }; @@ -975,8 +1011,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 = ""; }; @@ -985,24 +1019,19 @@ 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 = ""; }; - 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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -1012,9 +1041,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 = ""; }; - 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 = ""; }; @@ -1023,19 +1049,13 @@ 34E3E5671EC4B19400495BAC /* AudioProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioProgressView.swift; sourceTree = ""; }; 34F308A01ECB469700BB7697 /* OWSBezierPathView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSBezierPathView.h; sourceTree = ""; }; 34F308A11ECB469700BB7697 /* OWSBezierPathView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSBezierPathView.m; sourceTree = ""; }; - 36098A00B2C7DB91D85A4AE3 /* Pods-Session.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Session.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Session/Pods-Session.debug.xcconfig"; sourceTree = ""; }; - 37C61C4A1D3A11D3FC03FE22 /* 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 = ""; }; - 40E4C5D2ABFC1940BEB88BB9 /* Pods-Session.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Session.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-Session/Pods-Session.app store release.xcconfig"; sourceTree = ""; }; - 435EAC2E5E22D3F087EB3192 /* Pods-SignalShareExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.app store release.xcconfig"; sourceTree = ""; }; 4503F1BB20470A5B00CEE724 /* classic-quiet.aifc */ = {isa = PBXFileReference; lastKnownFileType = file; path = "classic-quiet.aifc"; sourceTree = ""; }; 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 = ""; }; - 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 = ""; }; @@ -1073,8 +1093,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 = ""; }; @@ -1082,10 +1100,10 @@ 45CD81EE1DC030E7004C9430 /* SyncPushTokensJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncPushTokensJob.swift; sourceTree = ""; }; 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarqueeLabel.swift; sourceTree = ""; }; 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaPageViewController.swift; path = "Session/Media Viewing & Editing/MediaPageViewController.swift"; sourceTree = SOURCE_ROOT; }; + 48AD214D67ABED845101E795 /* Pods_GlobalDependencies_Session.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_Session.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; @@ -1096,31 +1114,27 @@ 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 = ""; }; - 56298B3B5A12567B30355B67 /* Pods-GlobalDependencies-Session.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-Session.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session.app store release.xcconfig"; sourceTree = ""; }; - 5F3070F3395081DD0EB4F933 /* Pods-SignalUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalUtilitiesKit/Pods-SignalUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; - 65CC2A54604AED4D817AEAD7 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 69349DE607F5BA6036C9AC60 /* Pods-SignalShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.debug.xcconfig"; sourceTree = ""; }; - 698ED62BE96164824CE7CC18 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; - 69ED39F18BDEC3C861B8044F /* 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 = ""; }; - 6A26D6558DE69AF455E571C1 /* Pods-SessionMessagingKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.debug.xcconfig"; sourceTree = ""; }; - 6AD66810558DCCC8D6FD14C6 /* Pods-SessionPushNotificationExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionPushNotificationExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionPushNotificationExtension/Pods-SessionPushNotificationExtension.app store release.xcconfig"; sourceTree = ""; }; + 506FA2159653FF9F446D97D1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig"; sourceTree = ""; }; + 5442DF945D862CEDF7F8AC49 /* 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; }; + 5626DC0D5F62C1C2C64E4AFC /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig"; sourceTree = ""; }; + 56F41C56FC7B2F381E440FB0 /* Pods-GlobalDependencies-Session.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-Session.debug.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session.debug.xcconfig"; sourceTree = ""; }; + 58A6BA91F634756FA0BEC9E5 /* 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 = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.app store release.xcconfig"; sourceTree = ""; }; + 5DEB8C073F5E3C418BFB96C3 /* Pods_SessionUIKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SessionUIKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6737124ECBC2DFEE2DD716D3 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6BE8FBF62464A7177034A0AB /* Pods-GlobalDependencies-Session.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-Session.app store release.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session.app store release.xcconfig"; sourceTree = ""; }; + 6F84A214B9A1C0CCF6DB09C8 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 70377AAA1918450100CAF501 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; - 71CFEDD2D3C54277731012DF /* Pods_SessionUIKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SessionUIKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 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 = ""; }; + 782B65234A707D762FEAFD3B /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 7A8A44E3F8AC9282AC5E6E5A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7B0EFDED274F598600FFAAE7 /* TimestampUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimestampUtils.swift; sourceTree = ""; }; 7B0EFDEF275084AA00FFAAE7 /* CallMessageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessageCell.swift; sourceTree = ""; }; - 7B0EFDF1275449AA00FFAAE7 /* TSInfoMessage+Calls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSInfoMessage+Calls.swift"; sourceTree = ""; }; 7B0EFDF3275490EA00FFAAE7 /* ringing.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = ringing.mp3; sourceTree = ""; }; 7B0EFDF52755CC5400FFAAE7 /* CallMissedTipsModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMissedTipsModal.swift; sourceTree = ""; }; 7B13E1E82810F01300BD4F64 /* SessionCallManager+Action.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCallManager+Action.swift"; sourceTree = ""; }; 7B13E1EA2811138200BD4F64 /* PrivacySettingsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsTableViewController.swift; sourceTree = ""; }; - 7B1581E1271E743B00848B49 /* OWSSounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSSounds.swift; sourceTree = ""; }; 7B1581E3271FC59C00848B49 /* CallModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallModal.swift; sourceTree = ""; }; 7B1581E5271FD2A100848B49 /* VideoPreviewVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPreviewVC.swift; sourceTree = ""; }; 7B1581E727210ECC00848B49 /* RenderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderView.swift; sourceTree = ""; }; @@ -1130,31 +1144,25 @@ 7B2DB2AD26F1B0FF0035B509 /* si */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = si; path = si.lproj/Localizable.strings; sourceTree = ""; }; 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsendRequest.swift; sourceTree = ""; }; 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessageView.swift; sourceTree = ""; }; - 7B703746283CA919000DCF35 /* Storage+Calls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Calls.swift"; sourceTree = ""; }; 7B7CB188270430D20079FF93 /* CallMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessageView.swift; sourceTree = ""; }; 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogsModal.swift; sourceTree = ""; }; 7B7CB18D270D066F0079FF93 /* IncomingCallBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallBanner.swift; sourceTree = ""; }; 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 = ""; }; - 7B93D07227CF19C800811CB6 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.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 = ""; }; 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 = ""; }; 7BAF54D227ACCF01003D12F8 /* SAEScreenLockViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SAEScreenLockViewController.swift; sourceTree = ""; }; 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; - 7BAF54D627ACD0E3003D12F8 /* String+Localization.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; - 7BAF54D727ACD0E3003D12F8 /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; 7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.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 = ""; }; @@ -1163,8 +1171,6 @@ 7BCD116B27016062006330F1 /* WebRTCSession+DataChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTCSession+DataChannel.swift"; sourceTree = ""; }; 7BD477A727EC39F5004E2822 /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; 7BD477A927F15F24004E2822 /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = ""; }; - 7BD477AB27F15F41004E2822 /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = ""; }; - 7BD477AD27F526E3004E2822 /* BlockingManagerRemovalMigration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockingManagerRemovalMigration.swift; sourceTree = ""; }; 7BD477AF27F526FF004E2822 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.swift; sourceTree = ""; }; 7BDCFC0424206E7300641C39 /* SessionNotificationServiceExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SessionNotificationServiceExtension.entitlements; sourceTree = ""; }; 7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtensionContext.swift; sourceTree = ""; }; @@ -1172,26 +1178,14 @@ 7BFD1A8B2747150E00FB91B9 /* TurnServerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TurnServerInfo.swift; sourceTree = ""; }; 7BFD1A962747689000FB91B9 /* Session-Turn-Server */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "Session-Turn-Server"; sourceTree = ""; }; 7BFFB33B27D02F5800BEA04E /* CallPermissionRequestModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallPermissionRequestModal.swift; sourceTree = ""; }; - 7DD180F770F8518B4E8796F2 /* Pods-SessionUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUtilitiesKit/Pods-SessionUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; - 826CF3AB370207485081AD78 /* 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 = ""; }; - 848B0C04B8211741A916EE49 /* 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 = ""; }; - 88A804919A55752B13ACE3A5 /* Pods_GlobalDependencies_Session.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_Session.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 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 = ""; }; - 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 = ""; }; + 82099864FD91C9126A750313 /* 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 = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; sourceTree = ""; }; + 8E029A324780A800DE6B70B3 /* 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 = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig"; sourceTree = ""; }; + 96ED0C9B69379BE6FF4E9DA6 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.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; }; 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; }; - A4014869E7FA81AE97FD43B4 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - A5509EC91A69AB8B00ABA4BC /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - B27A64C349BBE85670300948 /* Pods-SessionShareExtension.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionShareExtension.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionShareExtension/Pods-SessionShareExtension.app store release.xcconfig"; sourceTree = ""; }; B60EDE031A05A01700D73516 /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = System/Library/Frameworks/AudioToolbox.framework; sourceTree = SDKROOT; }; B646D10E1AA5461A004133BA /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; B657DDC91911A40500F45B0C /* Signal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Signal.entitlements; sourceTree = ""; }; @@ -1218,11 +1212,9 @@ 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 = ""; }; - 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 = ""; }; @@ -1235,8 +1227,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 = ""; }; @@ -1246,7 +1236,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 = ""; }; @@ -1258,23 +1247,19 @@ 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 = ""; }; B8856D5F256F129B001CE70E /* OWSAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSAlerts.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; - 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 = ""; }; B8B558F026C4BB0600693325 /* CameraManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraManager.swift; sourceTree = ""; }; B8B558FE26C4E05E00693325 /* WebRTCSession+MessageHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTCSession+MessageHandling.swift"; sourceTree = ""; }; @@ -1286,7 +1271,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 = ""; }; @@ -1294,8 +1279,6 @@ B8BC00BF257D90E30032E807 /* General.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = General.swift; sourceTree = ""; }; B8BF43B926CC95FB007828D1 /* WebRTC+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTC+Utilities.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 = ""; }; @@ -1306,60 +1289,41 @@ 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 = ""; }; - 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 = ""; }; B8DE1FAF26C228780079C9CE /* SignalRingRTC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SignalRingRTC.framework; path = Dependencies/SignalRingRTC.framework; sourceTree = ""; }; B8DE1FB326C22F2F0079C9CE /* WebRTCSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCSession.swift; sourceTree = ""; }; B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMessage.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 = ""; }; - 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 = ""; }; 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 = ""; }; + BE11AFA6FD8CAE894CABC28D /* 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 = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.debug.xcconfig"; sourceTree = ""; }; C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Profile.swift"; sourceTree = ""; }; C300A5BC2554B00D00555489 /* ReadReceipt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceipt.swift; sourceTree = ""; }; C300A5D22554B05A00555489 /* TypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingIndicator.swift; sourceTree = ""; }; 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; + 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 = ""; }; @@ -1369,209 +1333,96 @@ 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 = ""; }; - 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; + 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - C33FDBA9255A581500E217F9 /* OWSIdentityManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSIdentityManager.m; 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - C33FDBEC255A581B00E217F9 /* OWSRecipientIdentity.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSRecipientIdentity.m; 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - 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 = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; 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 = ""; }; 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; }; @@ -1590,52 +1441,27 @@ 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; }; - 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; }; - 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; }; 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; }; - 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; }; C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAudioPlayer.h; path = SessionMessagingKit/Utilities/OWSAudioPlayer.h; sourceTree = SOURCE_ROOT; }; 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; }; 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; }; 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; }; @@ -1648,7 +1474,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; }; @@ -1659,9 +1484,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; }; - 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; }; @@ -1678,10 +1500,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; }; - 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; }; @@ -1694,11 +1512,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; }; @@ -1727,17 +1542,12 @@ 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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -1751,7 +1561,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 = ""; }; @@ -1766,7 +1576,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 = ""; }; @@ -1778,26 +1587,18 @@ 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 = ""; }; - 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 = ""; }; + C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupManager.swift; sourceTree = ""; }; + C3DB66C2260ACCE6001EFC55 /* OpenGroupPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupPoller.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 = ""; }; 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 = ""; }; 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 = ""; }; - 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 = ""; }; - CA4942875292B7BD5C0C02A6 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig"; sourceTree = ""; }; + D0CE0424239A1574F683D2D7 /* 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 = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig"; sourceTree = ""; }; 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; }; @@ -1805,30 +1606,246 @@ 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 = ""; }; D2AEACDB16C426DA00C364C0 /* CFNetwork.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; }; - 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 = ""; }; - DEEF92E2CA5FAADF72A46E13 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 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 = ""; }; + DAF57FAAF30631D0E99DA361 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig"; sourceTree = ""; }; + DBA125424EDD2417B515C63A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E1A0AD8B16E13FDD0071E604 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; - E631A7167783FA9D1FFBC453 /* Pods-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionNotificationServiceExtension/Pods-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; - E77FE0A560DE43C5741FB252 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - E7E2FBF1546840C91B7E4879 /* Pods-SessionUtilities.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUtilities.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionUtilities/Pods-SessionUtilities.debug.xcconfig"; sourceTree = ""; }; - E85DB184824BA9DC302EC8B3 /* Pods-SignalTests.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalTests.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalTests/Pods-SignalTests.app store release.xcconfig"; sourceTree = ""; }; - E8B3E83E635D96DC8F4EFD9E /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E23C1E6B7E0C12BF4ACD9CBE /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig"; sourceTree = ""; }; + EC5C23F9D234F558BE5E41DE /* Pods-SessionUIKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionUIKit.debug.xcconfig"; path = "Target Support Files/Pods-SessionUIKit/Pods-SessionUIKit.debug.xcconfig"; sourceTree = ""; }; 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 = ""; }; - F04906EA72326B6CF4FF859E /* 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 = ""; }; - 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 = ""; }; - 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 = ""; }; - F7A2E3D105D13A663129EA2C /* 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 = ""; }; - 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 = ""; }; + F705826F79C4A591AB35D68F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.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; }; - 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 = ""; }; + FD078E4727E02561000769AF /* CommonMockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonMockedExtensions.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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 /* 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 /* 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 /* SnodeAPIEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeAPIEndpoint.swift; sourceTree = ""; }; + FD17D7D327F6584600122BE0 /* OnionRequestAPIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnionRequestAPIError.swift; sourceTree = ""; }; + FD17D7D727F658E200122BE0 /* OnionRequestAPIDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnionRequestAPIDestination.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 /* _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; path = Configuration.swift; sourceTree = ""; }; + FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockListUIUtils.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 = ""; }; + 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 /* 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 = ""; }; + FD3C907027E445E500CD579F /* MessageReceiverDecryptionSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverDecryptionSpec.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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + FD83B9D127D59495005E1583 /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.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 = ""; }; + FD848B9928442CE6000E298B /* StorageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageError.swift; sourceTree = ""; }; + FD848B9B284435D7000E298B /* AppSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSetup.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 /* 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileManagerError.swift; 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 = ""; }; + 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 = ""; }; + FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestOnionRequestAPI.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 = ""; }; + 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 = ""; }; + FDC4385027B4807400C60D73 /* QueryParam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryParam.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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + FDC438B027BB159600C60D73 /* RequestInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInfo.swift; sourceTree = ""; }; + 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 /* 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 /* 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + FF694C71BE4B41B6AFD252A0 /* 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 = "Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1841,7 +1858,7 @@ C3D90A5C25773A25002C9DF5 /* SessionUtilitiesKit.framework in Frameworks */, C3402FE52559036600EA6424 /* SessionUIKit.framework in Frameworks */, B8D64FCB25BA78A90029CFC0 /* SignalUtilitiesKit.framework in Frameworks */, - 65BF14F694829B7A7F38C806 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework in Frameworks */, + 1FFD68A448D5A1439F2F02FD /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1853,7 +1870,7 @@ B8D64FBD25BA78310029CFC0 /* SessionSnodeKit.framework in Frameworks */, B8D64FBE25BA78310029CFC0 /* SessionUtilitiesKit.framework in Frameworks */, C38EF00C255B61CC007E1867 /* SignalUtilitiesKit.framework in Frameworks */, - 26E5526A63EE57E6252B6E3F /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */, + 98547545DAF8E7916DF9F0BF /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1861,7 +1878,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 10AC6C7D50A0C865C5E4779B /* Pods_SessionUIKit.framework in Frameworks */, + 63A15A5E6605581543B4A5B4 /* Pods_SessionUIKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1873,7 +1890,7 @@ C33FD9C2255A54EF00E217F9 /* SessionMessagingKit.framework in Frameworks */, C33FD9C4255A54EF00E217F9 /* SessionSnodeKit.framework in Frameworks */, C33FD9C5255A54EF00E217F9 /* SessionUtilitiesKit.framework in Frameworks */, - A13FC3642BE5D37A004D0EC8 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework in Frameworks */, + 3289CA2E9E89DA9D4D52A90C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1882,7 +1899,7 @@ buildActionMask = 2147483647; files = ( C3C2A6C62553896A00C340D1 /* SessionUtilitiesKit.framework in Frameworks */, - B6BC1D4DFFDA548179F75EC6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework in Frameworks */, + C2CAA4A9737D865B34560B8C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1890,7 +1907,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B3CDA9D910FEC0BE298B7243 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework in Frameworks */, + 5163CBC4F53274C88D1F88F8 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1898,9 +1915,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7B251C3627D82D9E001A6284 /* SessionUtilitiesKit.framework in Frameworks */, + FDC4386C27B4E90300C60D73 /* SessionUtilitiesKit.framework in Frameworks */, C3C2A70B25539E1E00C340D1 /* SessionSnodeKit.framework in Frameworks */, - 96EB4427CAAFFFC92E52573C /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework in Frameworks */, + CEE449BA3596483519120D91 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1939,13 +1956,58 @@ D221A090169C9E5E00537ABF /* Foundation.framework in Frameworks */, D221A0E8169DFFC500537ABF /* AVFoundation.framework in Frameworks */, D24B5BD5169F568C00681372 /* AudioToolbox.framework in Frameworks */, - 58DC2E64CCCE074490C984EB /* Pods_GlobalDependencies_Session.framework in Frameworks */, + 71B1D8AF3ADE2BD191256496 /* Pods_GlobalDependencies_Session.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FD83B9AC27CF200A005E1583 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FD83B9B327CF200A005E1583 /* SessionUtilitiesKit.framework in Frameworks */, + A49760F37A9AE09D57ECE415 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FDC4388B27B9FFC700C60D73 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + FDC4389227B9FFC700C60D73 /* SessionMessagingKit.framework in Frameworks */, + 92EB2776D36B22D2E0552A05 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2BADBA206E0B8D297E313FBA /* Pods */ = { + isa = PBXGroup; + children = ( + 506FA2159653FF9F446D97D1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig */, + FF694C71BE4B41B6AFD252A0 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig */, + D0CE0424239A1574F683D2D7 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig */, + 8E029A324780A800DE6B70B3 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig */, + 96ED0C9B69379BE6FF4E9DA6 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig */, + 5626DC0D5F62C1C2C64E4AFC /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig */, + F705826F79C4A591AB35D68F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */, + 2581AFACDDDC1404866D7B8C /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig */, + BE11AFA6FD8CAE894CABC28D /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.debug.xcconfig */, + 58A6BA91F634756FA0BEC9E5 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests.app store release.xcconfig */, + DAF57FAAF30631D0E99DA361 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig */, + 82099864FD91C9126A750313 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */, + 1A0882BF820F5B44969F91F1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */, + 245BF74EF6348E2D4125033F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig */, + E23C1E6B7E0C12BF4ACD9CBE /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig */, + 0E836037CC97CE5A47735596 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig */, + 56F41C56FC7B2F381E440FB0 /* Pods-GlobalDependencies-Session.debug.xcconfig */, + 6BE8FBF62464A7177034A0AB /* Pods-GlobalDependencies-Session.app store release.xcconfig */, + EC5C23F9D234F558BE5E41DE /* Pods-SessionUIKit.debug.xcconfig */, + 29CF8C79F41BF00B1C2E59A0 /* Pods-SessionUIKit.app store release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; 34074F54203D0722004596AE /* Sounds */ = { isa = PBXGroup; children = ( @@ -2024,6 +2086,7 @@ 7BAF54D127ACCF01003D12F8 /* ShareAppExtensionContext.swift */, 4535186C1FC635DD00210559 /* MainInterface.storyboard */, C3ADC66026426688005F1414 /* ShareVC.swift */, + FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */, B817AD9B26436F73009DF825 /* ThreadPickerVC.swift */, B817AD9926436593009DF825 /* SimplifiedConversationCell.swift */, ); @@ -2034,14 +2097,11 @@ isa = PBXGroup; children = ( 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */, - 451166BF1FD86B98000739BA /* AccountManager.swift */, 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, 34D5CCA71EAE3D30005515DB /* AvatarViewHelper.h */, 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */, - 4CC1ECFA211A553000CC13BE /* AppUpdateNag.swift */, - B90418E4183E9DD40038554A /* DateUtil.h */, - B90418E5183E9DD40038554A /* DateUtil.m */, - 4C21D5D5223A9DC500EF8A77 /* UIAlerts+iOS9.m */, + FD848B9728422F1A000E298B /* Date+Utilities.swift */, + FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, EF764C331DB67CC5000D9A87 /* UIViewController+Permissions.h */, @@ -2050,17 +2110,16 @@ 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 */, - C31FFE56254A5FFE00F19441 /* KeyPairUtilities.swift */, B84664F4235022F30083A1CD /* MentionUtilities.swift */, B886B4A82398BA1500211ABE /* QRCode.swift */, B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, + FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */, + FD848B8E283EF2A8000E298B /* UIScrollView+Utilities.swift */, C31A6C59247F214E001123EF /* UIView+Glow.swift */, C3548F0724456AB6009433A8 /* UIView+Wrapping.swift */, 7BAADFCD27B215FE007BCF92 /* UIView+Draggable.swift */, @@ -2084,19 +2143,13 @@ 7B93D06827CF173D00811CB6 /* Message Requests */ = { isa = PBXGroup; children = ( + FD716E6F28505E5100C96BF4 /* Views */, + FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */, 7B93D06927CF173D00811CB6 /* MessageRequestsViewController.swift */, ); path = "Message Requests"; sourceTree = ""; }; - 7B93D06B27CF175800811CB6 /* Views */ = { - isa = PBXGroup; - children = ( - 7B93D06C27CF175800811CB6 /* MessageRequestsCell.swift */, - ); - path = Views; - sourceTree = ""; - }; 7BA68907272A279900EFC32F /* Call Management */ = { isa = PBXGroup; children = ( @@ -2112,7 +2165,6 @@ 7BAF54CA27ACCEEC003D12F8 /* GlobalSearch */ = { isa = PBXGroup; children = ( - 7BAF54CB27ACCEEC003D12F8 /* Storage+RecentSearchResults.swift */, 7BAF54CC27ACCEEC003D12F8 /* GlobalSearchViewController.swift */, 7BAF54CD27ACCEEC003D12F8 /* EmptySearchResultCell.swift */, ); @@ -2138,63 +2190,6 @@ path = TurnServers; sourceTree = ""; }; - 9404664EC513585B05DF1350 /* Pods */ = { - isa = PBXGroup; - children = ( - DE2DD605305BC6EFAD731723 /* Pods-Signal.debug.xcconfig */, - DF728B4B438716EAF95CEC18 /* Pods-Signal.app store release.xcconfig */, - AD2AB1207E8888E4262D781B /* Pods-SignalTests.debug.xcconfig */, - E85DB184824BA9DC302EC8B3 /* Pods-SignalTests.app store release.xcconfig */, - 1CE3CD5C23334683BDD3D78C /* Pods-Signal.test.xcconfig */, - 8981C8F64D94D3C52EB67A2C /* Pods-SignalTests.test.xcconfig */, - 69349DE607F5BA6036C9AC60 /* Pods-SignalShareExtension.debug.xcconfig */, - 1C93CF3971B64E8B6C1F9AC1 /* Pods-SignalShareExtension.test.xcconfig */, - 435EAC2E5E22D3F087EB3192 /* Pods-SignalShareExtension.app store release.xcconfig */, - 9B533A9FA46206D3D99C9ADA /* Pods-SignalMessaging.debug.xcconfig */, - 948239851C08032C842937CC /* Pods-SignalMessaging.test.xcconfig */, - 8EEE74B0753448C085B48721 /* Pods-SignalMessaging.app store release.xcconfig */, - F62ECF7B8AF4F8089AA705B3 /* Pods-LokiPushNotificationService.debug.xcconfig */, - 18D19142FD6E60FD0A5D89F7 /* Pods-LokiPushNotificationService.app store release.xcconfig */, - A6344D429FFAC3B44E6A06FA /* Pods-SessionSnodeKit.debug.xcconfig */, - C022DD8E076866C6241610BF /* Pods-SessionSnodeKit.app store release.xcconfig */, - E7E2FBF1546840C91B7E4879 /* Pods-SessionUtilities.debug.xcconfig */, - 3303495F6651CE2F3CC9693B /* Pods-SessionUtilities.app store release.xcconfig */, - 6A26D6558DE69AF455E571C1 /* Pods-SessionMessagingKit.debug.xcconfig */, - FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */, - AEA8083C060FF9BAFF6E0C9F /* Pods-SessionProtocolKit.debug.xcconfig */, - 174BD0AE74771D02DAC2B7A9 /* Pods-SessionProtocolKit.app store release.xcconfig */, - 264033E641846B67E0CB21B0 /* Pods-SessionUtilitiesKit.debug.xcconfig */, - 7DD180F770F8518B4E8796F2 /* Pods-SessionUtilitiesKit.app store release.xcconfig */, - C88965DE4F4EC4FC919BEC4E /* Pods-SessionUIKit.debug.xcconfig */, - C1A746BC424B531D8ED478F6 /* Pods-SessionUIKit.app store release.xcconfig */, - 5F3070F3395081DD0EB4F933 /* Pods-SignalUtilitiesKit.debug.xcconfig */, - 9C0469AC557930C01552CC83 /* Pods-SignalUtilitiesKit.app store release.xcconfig */, - 36098A00B2C7DB91D85A4AE3 /* Pods-Session.debug.xcconfig */, - 40E4C5D2ABFC1940BEB88BB9 /* Pods-Session.app store release.xcconfig */, - F121FB43E2A1C1CF7F2AFC23 /* Pods-SessionPushNotificationExtension.debug.xcconfig */, - 6AD66810558DCCC8D6FD14C6 /* Pods-SessionPushNotificationExtension.app store release.xcconfig */, - F9BBF530D71905BA9007675F /* Pods-SessionShareExtension.debug.xcconfig */, - B27A64C349BBE85670300948 /* Pods-SessionShareExtension.app store release.xcconfig */, - E631A7167783FA9D1FFBC453 /* Pods-SessionNotificationServiceExtension.debug.xcconfig */, - C98441E849C3CA7FE8220D33 /* Pods-SessionNotificationServiceExtension.app store release.xcconfig */, - 848B0C04B8211741A916EE49 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig */, - 1758FCA85C98360EBA3949CF /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig */, - 0F1A8805563934A3324676D1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig */, - 0403B6AF17DFAD629A3AF862 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig */, - F04906EA72326B6CF4FF859E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */, - 698ED62BE96164824CE7CC18 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig */, - 826CF3AB370207485081AD78 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig */, - 69ED39F18BDEC3C861B8044F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */, - F7A2E3D105D13A663129EA2C /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */, - 08EF72D6EB5CDC49C863781E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig */, - 292F8FB4C82D8FF94571D837 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig */, - CA4942875292B7BD5C0C02A6 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig */, - 37C61C4A1D3A11D3FC03FE22 /* Pods-GlobalDependencies-Session.debug.xcconfig */, - 56298B3B5A12567B30355B67 /* Pods-GlobalDependencies-Session.app store release.xcconfig */, - ); - name = Pods; - sourceTree = ""; - }; B6B6C3C419193F5B00C0B76B /* Translations */ = { isa = PBXGroup; children = ( @@ -2250,9 +2245,9 @@ B82149B725D60393009C0F2A /* BlockedModal.swift */, C374EEE125DA26740073A857 /* LinkPreviewModal.swift */, B82149C025D605C6009C0F2A /* InfoBanner.swift */, - B8214A2A25D63EB9009C0F2A /* MessagesTableView.swift */, C374EEEA25DA3CA70073A857 /* ConversationTitleView.swift */, B848A4C4269EAAA200617031 /* UserDetailsSheet.swift */, + FD4B200D283492210034334B /* InsetLockableTableView.swift */, 7B1581E3271FC59C00848B49 /* CallModal.swift */, 7BFFB33B27D02F5800BEA04E /* CallPermissionRequestModal.swift */, ); @@ -2262,21 +2257,15 @@ 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 */, + 4CC613352227A00400E21A3A /* ConversationSearch.swift */, ); path = Conversations; sourceTree = ""; @@ -2284,12 +2273,12 @@ B835247725C38D190089A44F /* Message Cells */ = { isa = PBXGroup; children = ( + B8041A7325C8F758003C2166 /* Content Views */, B835247825C38D880089A44F /* MessageCell.swift */, B835249A25C3AB650089A44F /* VisibleMessageCell.swift */, B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */, 7B0EFDEF275084AA00FFAAE7 /* CallMessageCell.swift */, B8041AA625C90927003C2166 /* TypingIndicatorCell.swift */, - B8041A7325C8F758003C2166 /* Content Views */, ); path = "Message Cells"; sourceTree = ""; @@ -2310,13 +2299,16 @@ B8A582AB258C64E800AFD84C /* Database */ = { isa = PBXGroup; children = ( + FD17D7E827F6A1B800122BE0 /* LegacyDatabase */, + FD17D7C827F546CE00122BE0 /* Migrations */, + FD17D7CB27F546F500122BE0 /* Models */, + FD17D7B427F51E6700122BE0 /* Types */, + FD17D7BB27F51F5C00122BE0 /* Utilities */, + FD848B9928442CE6000E298B /* StorageError.swift */, + FD28A4F527EAD44C00FF65E7 /* Storage.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 = ""; @@ -2354,8 +2346,6 @@ B8FF8EA525C11FEF004D1F22 /* IPv4.swift */, C3C2A5D92553860B00C340D1 /* JSON.swift */, C33FDAF2255A580500E217F9 /* ProxiedContentDownloader.swift */, - C352A3A42557B5F000338F3E /* TSRequest.h */, - C352A3A52557B60D00338F3E /* TSRequest.m */, ); path = Networking; sourceTree = ""; @@ -2365,13 +2355,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 = ""; @@ -2381,23 +2374,20 @@ children = ( 7BD477A727EC39F5004E2822 /* Atomic.swift */, 7BAF54D527ACD0E2003D12F8 /* ReusableView.swift */, - 7BAF54D627ACD0E3003D12F8 /* String+Localization.swift */, - 7BAF54D727ACD0E3003D12F8 /* UITableView+ReusableView.swift */, C33FDB8A255A581200E217F9 /* AppContext.h */, C33FDB85255A581100E217F9 /* AppContext.m */, C3C2A5D12553860800C340D1 /* Array+Utilities.swift */, + FDC4383D27B4708600C60D73 /* Atomic.swift */, + FDC438CC27BC641200C60D73 /* Set+Utilities.swift */, C33FDAA8255A57FF00E217F9 /* BuildConfiguration.swift */, B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */, - B8AE75A325A6C6A6001A84D2 /* Data+Trimming.swift */, - C3C2A5D52553860A00C340D1 /* Dictionary+Description.swift */, + FDFD645A27F26D4600808CA1 /* Data+Utilities.swift */, + FDC6D75F2862B3F600B04575 /* Dependencies.swift */, + C3C2A5D52553860A00C340D1 /* Dictionary+Utilities.swift */, B87EF18026377A1D00124B3C /* Features.swift */, B8BC00BF257D90E30032E807 /* General.swift */, C3C2A5CE2553860700C340D1 /* Logging.swift */, 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 */, @@ -2409,37 +2399,28 @@ C33FDB51255A580D00E217F9 /* NSUserDefaults+OWS.h */, C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */, C33FDB14255A580800E217F9 /* OWSMath.h */, + FD705A91278D051200F16121 /* ReusableView.swift */, + FD17D7AF27F4225C00122BE0 /* Set+Utilities.swift */, C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */, + FD77289D284EF1C50018502F /* Sodium+Utilities.swift */, C33FDB3F255A580C00E217F9 /* String+SSK.swift */, C3C2AC2D2553CBEB00C340D1 /* String+Trimming.swift */, + FD7728952849E7E90018502F /* String+Utilities.swift */, C38EF237255B6D65007E1867 /* UIDevice+featureSupport.swift */, + FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */, + FD7728972849E8110018502F /* UITableView+ReusableView.swift */, C35D0DB425AE5F1200B6BF49 /* UIEdgeInsets.swift */, C38EF23D255B6D66007E1867 /* UIView+OWS.h */, C38EF23E255B6D66007E1867 /* UIView+OWS.m */, + FD848B9228420164000E298B /* UnicodeScalar+Utilities.swift */, C38EF2EF255B6DBB007E1867 /* Weak.swift */, + FD5D201D27B0D87C00FEA984 /* SessionId.swift */, 7B7CB191271508AD0079FF93 /* CallRingTonePlayer.swift */, 7B0EFDED274F598600FFAAE7 /* TimestampUtils.swift */, ); path = General; sourceTree = ""; }; - B8A582B9258C696200AFD84C /* Messaging */ = { - isa = PBXGroup; - children = ( - C33FDBCA255A581700E217F9 /* LKGroupUtilities.h */, - C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */, - ); - path = Messaging; - sourceTree = ""; - }; - B8B3201F258B1A540020074B /* Contacts */ = { - isa = PBXGroup; - children = ( - B8B32020258B1A650020074B /* Contact.swift */, - ); - path = Contacts; - sourceTree = ""; - }; B8B558ED26C4B55F00693325 /* Calls */ = { isa = PBXGroup; children = ( @@ -2456,17 +2437,19 @@ B8CCF63B239757C10091D419 /* Shared */ = { isa = PBXGroup; children = ( + FD4B200A283367350034334B /* Models */, 4CA46F4B219CCC630038ABDE /* CaptionView.swift */, 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */, 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */, 34386A53207D271C009F5D9C /* NeverClearView.swift */, + FDE72153287FE4470093DF33 /* HighlightMentionBackgroundView.swift */, 34F308A01ECB469700BB7697 /* OWSBezierPathView.h */, 34F308A11ECB469700BB7697 /* OWSBezierPathView.m */, 34330AA11E79686200DF2FB9 /* OWSProgressView.h */, 34330AA21E79686200DF2FB9 /* OWSProgressView.m */, 45A6DAD51EBBF85500893231 /* ReminderView.swift */, C354E75923FE2A7600CE22E3 /* BaseVC.swift */, - B8BB82AA238F669C00BA5194 /* ConversationCell.swift */, + B8BB82AA238F669C00BA5194 /* FullConversationCell.swift */, 4542DF53208D40AC007B4E76 /* LoadingViewController.swift */, 340FC888204DAC8C007AEB0F /* OWSQRCodeScanningViewController.h */, 340FC896204DAC8C007AEB0F /* OWSQRCodeScanningViewController.m */, @@ -2488,18 +2471,14 @@ 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 = ""; }; - B8F5F61925EDE4B0003BF8D4 /* Data Extraction */ = { - isa = PBXGroup; - children = ( - B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */, - ); - path = "Data Extraction"; - sourceTree = ""; - }; B8FF8E6025C10D8B004D1F22 /* Countries */ = { isa = PBXGroup; children = ( @@ -2514,7 +2493,6 @@ children = ( C3C2A74325539EB700C340D1 /* Message.swift */, C352A30825574D8400338F3E /* Message+Destination.swift */, - C32C5A99256DBDC1003C73A2 /* Signal */, C300A5C62554B02D00555489 /* Visible Messages */, C300A5C72554B03900555489 /* Control Messages */, ); @@ -2528,7 +2506,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 */, ); @@ -2538,16 +2515,16 @@ C300A5C72554B03900555489 /* Control Messages */ = { isa = PBXGroup; children = ( - 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */, - 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */, C3C2A7702553A41E00C340D1 /* ControlMessage.swift */, B8DE1FB526C22FCB0079C9CE /* CallMessage.swift */, - C300A5BC2554B00D00555489 /* ReadReceipt.swift */, - C300A5D22554B05A00555489 /* TypingIndicator.swift */, C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */, + C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */, + 7B93D06E27CF194000811CB6 /* ConfigurationMessage+Convenience.swift */, B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */, C300A5E62554B07300555489 /* ExpirationTimerUpdate.swift */, - C3DA9C0625AE7396008F7C7E /* ConfigurationMessage.swift */, + 7B93D06F27CF194000811CB6 /* MessageRequestResponse.swift */, + C300A5BC2554B00D00555489 /* ReadReceipt.swift */, + C300A5D22554B05A00555489 /* TypingIndicator.swift */, 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */, ); path = "Control Messages"; @@ -2556,24 +2533,20 @@ C300A5F02554B08500555489 /* Sending & Receiving */ = { isa = PBXGroup; children = ( + FDF0B7562807F35E004C14C5 /* Errors */, C3D9E3B52567685D0040E4F3 /* Attachments */, - B8F5F61925EDE4B0003BF8D4 /* Data Extraction */, - C32C5B01256DC054003C73A2 /* Expiration */, C32C5D22256DD496003C73A2 /* Link Previews */, - C32C5D2D256DD4C4003C73A2 /* Mentions */, C379DC6825672B5E0002D4EB /* Notifications */, C32C59F8256DB5A6003C73A2 /* Pollers */, C32C5B1B256DC160003C73A2 /* Quotes */, - C32C5ADE256DBF7F003C73A2 /* Read Tracking */, C32C5995256DAF85003C73A2 /* Typing Indicators */, + FD7728A1284F0DF50018502F /* Message Handling */, 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 */, - C32C5A87256DBCF9003C73A2 /* MessageReceiver+Handling.swift */, ); path = "Sending & Receiving"; sourceTree = ""; @@ -2638,130 +2611,30 @@ 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 = ( C33FDB34255A580B00E217F9 /* ClosedGroupPoller.swift */, - C3DB66C2260ACCE6001EFC55 /* OpenGroupPollerV2.swift */, + C3DB66C2260ACCE6001EFC55 /* OpenGroupPoller.swift */, C33FDB3A255A580B00E217F9 /* Poller.swift */, ); 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 */, - 34B6A904218B4C90007C4606 /* TypingIndicatorInteraction.swift */, - 7B0EFDF1275449AA00FFAAE7 /* TSInfoMessage+Calls.swift */, - ); - 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 = ( - 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 = ( - C38EF398255B6DD9007E1867 /* OWSQuotedReplyModel.h */, - C38EF39A255B6DD9007E1867 /* OWSQuotedReplyModel.m */, - B840729F2565F1670037CB17 /* OWSQuotedReplyModel+Conversion.swift */, - C33FDAD5255A580300E217F9 /* TSQuotedMessage.h */, - C33FDB83255A581100E217F9 /* TSQuotedMessage.m */, - C32C5B3E256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift */, + FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */, ); path = Quotes; sourceTree = ""; }; - C32C5BB9256DC7C4003C73A2 /* To Do */ = { - isa = PBXGroup; - children = ( - C33FDAA0255A57FF00E217F9 /* OWSRecipientIdentity.h */, - C33FDBEC255A581B00E217F9 /* OWSRecipientIdentity.m */, - C38EF2D3255B6DAF007E1867 /* OWSUserProfile.h */, - C38EF2D1255B6DAF007E1867 /* OWSUserProfile.m */, - C33FDBB9255A581600E217F9 /* ProfileManagerProtocol.h */, - C33FDAEC255A580500E217F9 /* SignalRecipient.h */, - C33FDBB7255A581600E217F9 /* SignalRecipient.m */, - C33FDB94255A581300E217F9 /* TSAccountManager.h */, - C33FDB88255A581200E217F9 /* TSAccountManager.m */, - ); - path = "To Do"; - sourceTree = ""; - }; C32C5BCB256DC818003C73A2 /* Database */ = { isa = PBXGroup; children = ( - 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 */, - B8D8F1372566120F0092EF10 /* Storage+ClosedGroups.swift */, - B8B32032258B235D0020074B /* Storage+Contacts.swift */, - B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */, - B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */, - B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */, - C3F0A5FD255C988A007BE2A3 /* Storage+Shared.swift */, - 7B703746283CA919000DCF35 /* Storage+Calls.swift */, - C33FDA69255A57F900E217F9 /* SSKPreferences.swift */, - C33FDB25255A580900E217F9 /* TSDatabaseSecondaryIndexes.h */, - C33FDB20255A580900E217F9 /* TSDatabaseSecondaryIndexes.m */, - C33FDB2C255A580A00E217F9 /* TSDatabaseView.h */, - C33FDB46255A580C00E217F9 /* TSDatabaseView.m */, + FD17D79A27F40ADA00122BE0 /* LegacyDatabase */, + FD17D79427F3E03300122BE0 /* Migrations */, + FD09796C27FA6C8B00936362 /* Models */, ); path = Database; sourceTree = ""; @@ -2770,21 +2643,12 @@ isa = PBXGroup; children = ( B8B320B6258C30D70020074B /* HTMLMetadata.swift */, - C33FDBA8255A581500E217F9 /* OWSLinkPreview.swift */, - B8566C62256F55930045A0B9 /* OWSLinkPreview+Conversion.swift */, + FDB4BBC62838B91E00B7C95D /* LinkPreviewError.swift */, + C33FDBA8255A581500E217F9 /* LinkPreviewDraft.swift */, ); path = "Link Previews"; sourceTree = ""; }; - C32C5D2D256DD4C4003C73A2 /* Mentions */ = { - isa = PBXGroup; - children = ( - C33FDA7E255A57FB00E217F9 /* Mention.swift */, - C33FDA81255A57FC00E217F9 /* MentionsManager.swift */, - ); - path = Mentions; - sourceTree = ""; - }; C331FF1C2558F9D300070591 /* SessionUIKit */ = { isa = PBXGroup; children = ( @@ -2848,14 +2712,12 @@ children = ( C33FD9B7255A54A300E217F9 /* Meta */, C3F0A5EB255C970D007BE2A3 /* Configuration.swift */, - C38BBA0E255E32440041B9A3 /* Database */, C36096ED25AD20FD008B62B2 /* Media Viewing & Editing */, C38BBA0D255E321C0041B9A3 /* Messaging */, C36096EF25AD2268008B62B2 /* Profile Pictures */, C36096EE25AD21BC008B62B2 /* Screen Lock */, C3851CD225624B060061EEB0 /* Shared Views */, C360970125AD22D3008B62B2 /* Shared View Controllers */, - C3851CE3256250FA0061EEB0 /* To Do */, C3CA3B11255CF17200F4C6D4 /* Utilities */, ); path = SignalUtilitiesKit; @@ -2874,14 +2736,7 @@ C352A2F325574B3300338F3E /* Jobs */ = { isa = PBXGroup; children = ( - C352A2F425574B4700338F3E /* Job.swift */, - C352A3922557883D00338F3E /* JobDelegate.swift */, - C352A3882557876500338F3E /* JobQueue.swift */, - C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, - C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */, - C352A31225574F5200338F3E /* MessageReceiveJob.swift */, - C352A2FE25574B6300338F3E /* MessageSendJob.swift */, - C352A32E2557549C00338F3E /* NotifyPNServerJob.swift */, + FDF0B7452804F0A8004C14C5 /* Types */, ); path = Jobs; sourceTree = ""; @@ -2907,9 +2762,9 @@ C360968E25AD16E8008B62B2 /* Home */ = { isa = PBXGroup; children = ( - 7B93D06B27CF175800811CB6 /* Views */, 7B93D06827CF173D00811CB6 /* Message Requests */, 7BAF54CA27ACCEEC003D12F8 /* GlobalSearch */, + FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */, B8BB82A4238F627000BA5194 /* HomeVC.swift */, B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */, ); @@ -2932,6 +2787,7 @@ B886B4A62398B23E00211ABE /* QRCodeVC.swift */, B86BD08523399CEF000F5AE3 /* SeedModal.swift */, B8CCF6422397711F0091D419 /* SettingsVC.swift */, + FDE72117286C156E0093DF33 /* ChatSettingsViewController.swift */, 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */, ); path = Settings; @@ -2996,15 +2852,16 @@ C36096BA25AD1B14008B62B2 /* Media Viewing & Editing */ = { isa = PBXGroup; 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 */, - 452EC6DE205E9E30000E787C /* MediaGalleryViewController.swift */, - 45F32C1D205718B000A300D5 /* MediaPageViewController.swift */, - 454A84032059C787008B8C75 /* MediaTileViewController.swift */, 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */, 3496955A219B605E00DCFE74 /* PhotoCollectionPickerController.swift */, 3496955B219B605E00DCFE74 /* PhotoLibrary.swift */, @@ -3033,7 +2890,6 @@ C379DCEA2567334F0002D4EB /* Attachment Approval */, C379DCE9256733390002D4EB /* Image Editing */, C38EF358255B6DCC007E1867 /* MediaMessageView.swift */, - C38EF357255B6DCC007E1867 /* MessageApprovalViewController.swift */, C38EF227255B6D5D007E1867 /* OWSVideoPlayer.swift */, C38EF3B5255B6DE6007E1867 /* OWSViewController+ImageEditor.swift */, C38EF3E3255B6DF4007E1867 /* VideoPlayerView.swift */, @@ -3079,29 +2935,13 @@ C379DC6825672B5E0002D4EB /* Notifications */ = { isa = PBXGroup; children = ( - C33FDB7A255A581000E217F9 /* NotificationsProtocol.h */, + FDC4382D27B383A600C60D73 /* Models */, + FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */, C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */, ); path = Notifications; sourceTree = ""; }; - C379DCE82567330E0002D4EB /* Migrations */ = { - isa = PBXGroup; - children = ( - 7BD477AD27F526E3004E2822 /* BlockingManagerRemovalMigration.swift */, - 7BD477AB27F15F41004E2822 /* OpenGroupServerIdLookupMigration.swift */, - 7B93D07227CF19C800811CB6 /* MessageRequestsMigration.swift */, - B8B32044258C117C0020074B /* ContactsMigration.swift */, - C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */, - C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */, - C38EF26F255B6D79007E1867 /* OWSDatabaseMigrationRunner.h */, - C38EF26D255B6D79007E1867 /* OWSDatabaseMigrationRunner.m */, - C38EF26E255B6D79007E1867 /* OWSResaveCollectionDBMigration.h */, - C38EF26C255B6D79007E1867 /* OWSResaveCollectionDBMigration.m */, - ); - path = Migrations; - sourceTree = ""; - }; C379DCE9256733390002D4EB /* Image Editing */ = { isa = PBXGroup; children = ( @@ -3160,62 +3000,24 @@ path = "Shared Views"; sourceTree = ""; }; - C3851CE3256250FA0061EEB0 /* To Do */ = { - isa = PBXGroup; - children = ( - C33FDB19255A580900E217F9 /* GroupUtilities.swift */, - C38EF3E5255B6DF4007E1867 /* ContactCellView.h */, - C38EF3D6255B6DEF007E1867 /* ContactCellView.m */, - C38EF3E6255B6DF4007E1867 /* ContactTableViewCell.h */, - C38EF3EB255B6DF6007E1867 /* ContactTableViewCell.m */, - C38EF2D2255B6DAF007E1867 /* OWSProfileManager.h */, - C38EF2CF255B6DAE007E1867 /* OWSProfileManager.m */, - ); - path = "To Do"; - sourceTree = ""; - }; C38BBA0D255E321C0041B9A3 /* Messaging */ = { isa = PBXGroup; children = ( + FD28A4F327EA79F800FF65E7 /* BlockListUIUtils.swift */, 7BD477AF27F526FF004E2822 /* 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 */, - C38EF2E3255B6DB9007E1867 /* OWSUnreadIndicator.m */, - C33FDAE8255A580500E217F9 /* OWSMessageUtils.h */, - C33FDBD7255A581900E217F9 /* OWSMessageUtils.m */, ); path = Messaging; sourceTree = ""; }; - C38BBA0E255E32440041B9A3 /* Database */ = { - 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; - sourceTree = ""; - }; C3A721332558BDDF0043A11F /* Open Groups */ = { isa = PBXGroup; children = ( + FDC4381827B34EAD00C60D73 /* Models */, + FDC4380727B31D3A00C60D73 /* Types */, + FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */, 7BD477A927F15F24004E2822 /* OpenGroupServerIdLookup.swift */, - C3DB6694260AC923001EFC55 /* OpenGroupV2.swift */, - B88FA7B726045D100049422F /* OpenGroupAPIV2.swift */, - C3DB66CB260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift */, - C3DB66AB260ACA42001EFC55 /* OpenGroupManagerV2.swift */, - C3227FF5260AAD66006EA627 /* OpenGroupMessageV2.swift */, + B88FA7B726045D100049422F /* OpenGroupAPI.swift */, + C3DB66AB260ACA42001EFC55 /* OpenGroupManager.swift */, ); path = "Open Groups"; sourceTree = ""; @@ -3223,7 +3025,9 @@ C3A7215C2558C0AC0043A11F /* File Server */ = { isa = PBXGroup; children = ( - B87EF17026367CF800124B3C /* FileServerAPIV2.swift */, + FDC4383227B385B200C60D73 /* Models */, + FD83B9CA27D179AF005E1583 /* Types */, + B87EF17026367CF800124B3C /* FileServerAPI.swift */, ); path = "File Server"; sourceTree = ""; @@ -3233,52 +3037,32 @@ children = ( C33FDB01255A580700E217F9 /* AppReadiness.h */, C33FDB75255A581000E217F9 /* AppReadiness.m */, + FDF0B7542807C4BB004C14C5 /* Environment.swift */, + FD859EF127BF6BA200510D0C /* Data+Utilities.swift */, + FDC438C027BB4E6800C60D73 /* SMKDependencies.swift */, C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, - C37F53E8255BA9BB002AEA92 /* Environment.h */, - C37F5402255BA9ED002AEA92 /* Environment.m */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, - C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */, - C33FDBC1255A581700E217F9 /* General.swift */, - B82A0C3726B9098200C1BCE3 /* MessageInvalidator.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C3A71D4E25589FF30043A11F /* NSData+messagePadding.h */, C3A71D4825589FF20043A11F /* NSData+messagePadding.m */, + FD09797E27FCFBFF00936362 /* OWSAES256Key+Utilities.swift */, C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, C38EF2F7255B6DBC007E1867 /* OWSAudioPlayer.m */, C38EF281255B6D84007E1867 /* OWSAudioSession.swift */, C33FDB38255A580B00E217F9 /* OWSBackgroundTask.h */, C33FDC1B255A581F00E217F9 /* OWSBackgroundTask.m */, - C33FDC05255A581D00E217F9 /* OWSDisappearingMessagesFinder.h */, - C33FDA86255A57FC00E217F9 /* OWSDisappearingMessagesFinder.m */, - C33FDBF1255A581B00E217F9 /* OWSIdentityManager.h */, - C33FDBA9255A581500E217F9 /* OWSIdentityManager.m */, - C33FDAC0255A580100E217F9 /* OWSIncomingMessageFinder.h */, - C33FDB1E255A580900E217F9 /* OWSIncomingMessageFinder.m */, - C33FDB67255A580F00E217F9 /* OWSMediaGalleryFinder.h */, - C33FDB71255A581000E217F9 /* OWSMediaGalleryFinder.m */, - C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */, - C38EF308255B6DBE007E1867 /* OWSPreferences.m */, - C38EF288255B6D85007E1867 /* OWSSounds.h */, - C38EF28B255B6D86007E1867 /* OWSSounds.m */, - 7B1581E1271E743B00848B49 /* OWSSounds.swift */, + FDF0B75D280AAF35004C14C5 /* Preferences.swift */, C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, + FD09797327FAB3E200936362 /* ProfileManager.swift */, + FDB4BBC82839BEF000B7C95D /* ProfileManagerError.swift */, C38EF2EC255B6DBA007E1867 /* ProximityMonitoringManager.swift */, - C3BBE0B42554F0E10050F1E3 /* ProofOfWork.swift */, - C33FDB91255A581200E217F9 /* ProtoUtils.h */, - C33FDA6C255A57FA00E217F9 /* ProtoUtils.m */, C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */, - C3E7134E251C867C009649BB /* Sodium+Conversion.swift */, - C33FDB31255A580A00E217F9 /* SSKEnvironment.h */, - C33FDAF4255A580600E217F9 /* SSKEnvironment.m */, - C33FDB32255A580A00E217F9 /* SSKIncrementingIdFinder.swift */, + FDC4386827B4E6B700C60D73 /* String+Utlities.swift */, + FDC4387327B5BB9B00C60D73 /* Promise+Utilities.swift */, + FD772899284AF1BD0018502F /* Sodium+Utilities.swift */, C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */, C3ECBF7A257056B700EA7FCE /* Threading.swift */, - C35D76DA26606303009AA5FB /* ThreadUpdateBatcher.swift */, - C33FDB5F255A580E00E217F9 /* YapDatabaseConnection+OWS.h */, - C33FDB43255A580C00E217F9 /* YapDatabaseConnection+OWS.m */, - C33FDA88255A57FD00E217F9 /* YapDatabaseTransaction+OWS.h */, - C33FDB5B255A580E00E217F9 /* YapDatabaseTransaction+OWS.m */, ); path = Utilities; sourceTree = ""; @@ -3287,17 +3071,16 @@ isa = PBXGroup; children = ( C3C2A5B0255385C700C340D1 /* Meta */, + FD17D79D27F40CAA00122BE0 /* Database */, + FDC438AF27BB158500C60D73 /* Models */, + C3C2A5CD255385F300C340D1 /* Utilities */, C3C2A5B9255385ED00C340D1 /* Configuration.swift */, C3C2A5BD255385EE00C340D1 /* Notification+OnionRequestAPI.swift */, C3C2A5BA255385ED00C340D1 /* OnionRequestAPI.swift */, C3C2A5BB255385ED00C340D1 /* OnionRequestAPI+Encryption.swift */, - C3C2A5B7255385EC00C340D1 /* Snode.swift */, C3C2A5BE255385EE00C340D1 /* SnodeAPI.swift */, + FD90040E2818AB6D00ABAAF6 /* GetSnodePoolJob.swift */, C3C2A5B6255385EC00C340D1 /* SnodeMessage.swift */, - C3C2A5B8255385EC00C340D1 /* Storage.swift */, - B8D8F1BC25661C6F0092EF10 /* Storage+OnionRequests.swift */, - C3F0A607255C98A6007BE2A3 /* Storage+SnodeAPI.swift */, - C3C2A5CD255385F300C340D1 /* Utilities */, ); path = SessionSnodeKit; sourceTree = ""; @@ -3330,10 +3113,12 @@ B8A582AC258C653C00AFD84C /* Crypto */, B8A582AB258C64E800AFD84C /* Database */, B8A582B0258C66C900AFD84C /* General */, + FD9004102818ABB000ABAAF6 /* JobRunner */, B8A582AF258C665E00AFD84C /* Media */, - B8A582B9258C696200AFD84C /* Messaging */, B8A582AE258C65D000AFD84C /* Networking */, B8A582AD258C655E00AFD84C /* PromiseKit */, + FD09796527F6B0A800936362 /* Utilities */, + FDCDB8EF2817ABCE00352A0C /* Utilities */, C3D9E43025676D3D0040E4F3 /* Configuration.swift */, ); path = SessionUtilitiesKit; @@ -3353,19 +3138,17 @@ children = ( C3C2A7802553AA6300C340D1 /* Protos */, C3C2A70A25539DF900C340D1 /* Meta */, - C32C5BB9256DC7C4003C73A2 /* To Do */, - C3BBE0752554CDA60050F1E3 /* Configuration.swift */, - C3BBE07F2554CDD70050F1E3 /* Storage.swift */, + FDC4384D27B47FD600C60D73 /* Common Networking */, B8DE1FB226C22F1F0079C9CE /* Calls */, - B8B3201F258B1A540020074B /* Contacts */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, - C32C59AF256DB31A003C73A2 /* Threads */, C300A5F02554B08500555489 /* Sending & Receiving */, C352A2F325574B3300338F3E /* Jobs */, C3A7215C2558C0AC0043A11F /* File Server */, C3A721332558BDDF0043A11F /* Open Groups */, + FD3E0C82283B581F002A425C /* Shared Models */, C3BBE0B32554F0D30050F1E3 /* Utilities */, + FD245C612850664300B966DD /* Configuration.swift */, ); path = SessionMessagingKit; sourceTree = ""; @@ -3382,6 +3165,8 @@ C3C2A7802553AA6300C340D1 /* Protos */ = { isa = PBXGroup; children = ( + FD859EEF27BF207700510D0C /* SessionProtos.proto */, + FD859EF027BF207C00510D0C /* WebSocketResources.proto */, C3C2A7812553AA9000C340D1 /* Generated */, ); path = Protos; @@ -3412,10 +3197,9 @@ C3CA3B11255CF17200F4C6D4 /* Utilities */ = { isa = PBXGroup; children = ( + FD848B9B284435D7000E298B /* AppSetup.swift */, + FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */, 7BAF54DB27ACD12B003D12F8 /* UIColor+Extensions.swift */, - C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */, - C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */, - C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */, C38EF3DC255B6DF1007E1867 /* DirectionalPanGestureRecognizer.swift */, C38EF240255B6D67007E1867 /* UIView+OWS.swift */, C38EF236255B6D65007E1867 /* UIViewController+OWS.h */, @@ -3436,11 +3220,8 @@ C33FDC0B255A581D00E217F9 /* OWSError.m */, C33FDBA1255A581400E217F9 /* OWSOperation.h */, C33FDB78255A581000E217F9 /* OWSOperation.m */, - 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 */, @@ -3449,14 +3230,8 @@ C38EF3E4255B6DF4007E1867 /* CommonStrings.swift */, C38EF304255B6DBE007E1867 /* ImageCache.swift */, C38EF2F2255B6DBC007E1867 /* Searcher.swift */, - B8C2B33B2563770800551B4D /* ThreadUtil.h */, - B8C2B331256376F000551B4D /* ThreadUtil.m */, - C38EF284255B6D84007E1867 /* AppSetup.h */, - C38EF287255B6D85007E1867 /* AppSetup.m */, B8856D5F256F129B001CE70E /* OWSAlerts.swift */, C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */, - C38EF283255B6D84007E1867 /* VersionMigrations.h */, - C38EF286255B6D85007E1867 /* VersionMigrations.m */, C33FDA8B255A57FD00E217F9 /* AppVersion.m */, C33FDB69255A580F00E217F9 /* FeatureFlags.swift */, C33FDA99255A57FE00E217F9 /* OutageDetection.swift */, @@ -3468,14 +3243,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 */, @@ -3494,15 +3263,8 @@ C3D9E3B52567685D0040E4F3 /* Attachments */ = { isa = PBXGroup; children = ( - C33FDAF1255A580500E217F9 /* OWSThumbnailService.swift */, + C33FDAF1255A580500E217F9 /* ThumbnailService.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 = ""; @@ -3510,8 +3272,6 @@ C3F0A58F255C8E3D007BE2A3 /* Meta */ = { isa = PBXGroup; children = ( - 76EB03C218170B33006006FC /* AppDelegate.h */, - 76EB03C318170B33006006FC /* AppDelegate.m */, C3AAFFF125AE99710089E6DD /* AppDelegate.swift */, 34D99CE3217509C1000AFB39 /* AppEnvironment.swift */, 7BFD1A952747689000FB91B9 /* TurnServers */, @@ -3520,15 +3280,12 @@ 34330A581E7875FB00DF2FB9 /* Fonts */, B66DBF4919D5BBC8006EA940 /* Images.xcassets */, 45CB2FA71CB7146C00E1B343 /* Launch Screen.storyboard */, - A5509EC91A69AB8B00ABA4BC /* Main.storyboard */, - D221A099169C9E5E00537ABF /* main.m */, 34B0796C1FCF46B000E248C2 /* MainAppContext.h */, 34B0796B1FCF46B000E248C2 /* MainAppContext.m */, 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 */, @@ -3551,9 +3308,13 @@ C3C2A6F125539DE700C340D1 /* SessionMessagingKit */, C3C2A5A0255385C100C340D1 /* SessionSnodeKit */, C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */, + FD83B9BC27CF2215005E1583 /* SharedTest */, + FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */, + FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */, + FDE7214E287E50D50093DF33 /* Scripts */, D221A08C169C9E5E00537ABF /* Frameworks */, D221A08A169C9E5E00537ABF /* Products */, - 9404664EC513585B05DF1350 /* Pods */, + 2BADBA206E0B8D297E313FBA /* Pods */, ); sourceTree = ""; }; @@ -3568,6 +3329,8 @@ C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */, C331FF1B2558F9D300070591 /* SessionUIKit.framework */, C33FD9AB255A548A00E217F9 /* SignalUtilitiesKit.framework */, + FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */, + FD83B9AF27CF200A005E1583 /* SessionUtilitiesKitTests.xctest */, ); name = Products; sourceTree = ""; @@ -3610,17 +3373,16 @@ D221A08D169C9E5E00537ABF /* UIKit.framework */, D221A08F169C9E5E00537ABF /* Foundation.framework */, D221A091169C9E5E00537ABF /* CoreGraphics.framework */, - 748A5CAEDD7C919FC64C6807 /* Pods_SignalTests.framework */, - 264242150E87D10A357DB07B /* Pods_SignalMessaging.framework */, - 2183DCA28E0620BC73FCC554 /* Pods_SessionProtocolKit.framework */, - 71CFEDD2D3C54277731012DF /* Pods_SessionUIKit.framework */, - E77FE0A560DE43C5741FB252 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework */, - DEEF92E2CA5FAADF72A46E13 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */, - 65CC2A54604AED4D817AEAD7 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */, - 19942D14DEF67D588752ADB2 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */, - E8B3E83E635D96DC8F4EFD9E /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */, - A4014869E7FA81AE97FD43B4 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework */, - 88A804919A55752B13ACE3A5 /* Pods_GlobalDependencies_Session.framework */, + 7A8A44E3F8AC9282AC5E6E5A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit.framework */, + 2691123A7F231EDD8226C4B5 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionMessagingKit_SessionMessagingKitTests.framework */, + DBA125424EDD2417B515C63A /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionShareExtension.framework */, + 782B65234A707D762FEAFD3B /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit.framework */, + 5442DF945D862CEDF7F8AC49 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SessionUtilitiesKit_SessionUtilitiesKitTests.framework */, + 0BF4561630A52BE96F164CF6 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_ExtendedDependencies_SignalUtilitiesKit.framework */, + 6F84A214B9A1C0CCF6DB09C8 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionNotificationServiceExtension.framework */, + 6737124ECBC2DFEE2DD716D3 /* Pods_GlobalDependencies_FrameworkAndExtensionDependencies_SessionSnodeKit.framework */, + 48AD214D67ABED845101E795 /* Pods_GlobalDependencies_Session.framework */, + 5DEB8C073F5E3C418BFB96C3 /* Pods_SessionUIKit.framework */, ); name = Frameworks; sourceTree = ""; @@ -3648,6 +3410,509 @@ path = Session; sourceTree = ""; }; + FD09796527F6B0A800936362 /* Utilities */ = { + isa = PBXGroup; + children = ( + FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */, + FD09796A27F6C67500936362 /* Failable.swift */, + FD09797127FAA2F500936362 /* Optional+Utilities.swift */, + FD09797C27FBDB2000936362 /* Notification+Utilities.swift */, + FDF222082818D2B0000A4995 /* NSAttributedString+Utilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + FD09796C27FA6C8B00936362 /* Models */ = { + isa = PBXGroup; + 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 */, + FD09799227FE693200936362 /* Interaction.swift */, + FD09799627FFA84900936362 /* RecipientState.swift */, + FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */, + FD09799827FFC1A300936362 /* Attachment.swift */, + FD09799A27FFC82D00936362 /* Quote.swift */, + FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, + FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */, + FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */, + FD5C7308285007920029977D /* BlindedIdLookup.swift */, + ); + path = Models; + sourceTree = ""; + }; + FD17D79427F3E03300122BE0 /* Migrations */ = { + isa = PBXGroup; + children = ( + FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */, + FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */, + FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */, + FDF40CDD2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift */, + ); + path = Migrations; + sourceTree = ""; + }; + FD17D79A27F40ADA00122BE0 /* LegacyDatabase */ = { + isa = PBXGroup; + children = ( + FD17D79B27F40B2E00122BE0 /* SMKLegacy.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 */, + FD6A7A6C2818C61500035AC1 /* _002_SetupStandardJobs.swift */, + FD17D7A327F40F8100122BE0 /* _003_YDBToGRDBMigration.swift */, + ); + path = Migrations; + sourceTree = ""; + }; + FD17D7A527F41ADE00122BE0 /* LegacyDatabase */ = { + isa = PBXGroup; + children = ( + FD17D7A627F41AF000122BE0 /* SSKLegacy.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 */, + FD17D7B927F51F2100122BE0 /* TargetMigrations.swift */, + FD17D7C027F5200100122BE0 /* TypedTableDefinition.swift */, + FD7162DA281B6C440060647B /* TypedTableAlias.swift */, + FD848B8A283DC509000E298B /* PagedDatabaseObserver.swift */, + ); + path = Types; + sourceTree = ""; + }; + FD17D7BB27F51F5C00122BE0 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD17D7C427F5206300122BE0 /* ColumnDefinition+Utilities.swift */, + FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */, + FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */, + FDF22210281B5E0B000A4995 /* TableRecord+Utilities.swift */, + FDF2220E281B55E6000A4995 /* QueryInterfaceRequest+Utilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + FD17D7C827F546CE00122BE0 /* Migrations */ = { + isa = PBXGroup; + children = ( + FD17D7C927F546D900122BE0 /* _001_InitialSetupMigration.swift */, + FD9004132818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift */, + FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */, + ); + path = Migrations; + sourceTree = ""; + }; + FD17D7CB27F546F500122BE0 /* Models */ = { + isa = PBXGroup; + children = ( + FD17D7E427F6A09900122BE0 /* Identity.swift */, + FDF0B73F280402C4004C14C5 /* Job.swift */, + FD09C5E1282212B3000CE219 /* JobDependencies.swift */, + FD17D7CC27F546FF00122BE0 /* Setting.swift */, + ); + path = Models; + sourceTree = ""; + }; + FD17D7E827F6A1B800122BE0 /* LegacyDatabase */ = { + isa = PBXGroup; + children = ( + FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */, + ); + path = LegacyDatabase; + 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 /* BlindedIdLookupSpec.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 = ""; + }; + FD3E0C82283B581F002A425C /* Shared Models */ = { + isa = PBXGroup; + children = ( + FD848B8C283E0B26000E298B /* MessageInputTypes.swift */, + FD848B86283B844B000E298B /* MessageViewModel.swift */, + FD3E0C83283B5835002A425C /* SessionThreadViewModel.swift */, + ); + path = "Shared Models"; + sourceTree = ""; + }; + FD4B200A283367350034334B /* Models */ = { + isa = PBXGroup; + children = ( + ); + path = Models; + sourceTree = ""; + }; + FD716E6F28505E5100C96BF4 /* Views */ = { + isa = PBXGroup; + children = ( + FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */, + ); + 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 */ = { + isa = PBXGroup; + children = ( + FD83B9B927CF20A5005E1583 /* General */, + ); + path = SessionUtilitiesKitTests; + sourceTree = ""; + }; + FD83B9B927CF20A5005E1583 /* General */ = { + isa = PBXGroup; + children = ( + FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */, + ); + path = General; + sourceTree = ""; + }; + FD83B9BC27CF2215005E1583 /* SharedTest */ = { + isa = PBXGroup; + children = ( + FDC290A527D860CE005DAE71 /* Mock.swift */, + FD83B9BD27CF2243005E1583 /* TestConstants.swift */, + FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */, + FD078E4727E02561000769AF /* CommonMockedExtensions.swift */, + ); + path = SharedTest; + sourceTree = ""; + }; + FD83B9C127CF33EE005E1583 /* Models */ = { + isa = PBXGroup; + children = ( + FD83B9C427CF3E2A005E1583 /* OpenGroupSpec.swift */, + FD3C905B27E3FBEF00CD579F /* BatchRequestInfoSpec.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 = ""; + }; + FD83B9CA27D179AF005E1583 /* Types */ = { + isa = PBXGroup; + children = ( + FD83B9CB27D179BC005E1583 /* FSEndpoint.swift */, + ); + path = Types; + sourceTree = ""; + }; + FD9004102818ABB000ABAAF6 /* JobRunner */ = { + isa = PBXGroup; + children = ( + FDF0B7432804EF1B004C14C5 /* JobRunner.swift */, + FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */, + ); + path = JobRunner; + 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 = ( + FDC4381F27B36ADC00C60D73 /* SOGSEndpoint.swift */, + FDC4380827B31D4E00C60D73 /* OpenGroupAPIError.swift */, + FDC4381627B32EC700C60D73 /* Personalization.swift */, + FDC4381427B329CE00C60D73 /* NonceGenerator.swift */, + FDC438C227BB512200C60D73 /* SodiumProtocols.swift */, + ); + path = Types; + sourceTree = ""; + }; + FDC4381827B34EAD00C60D73 /* Models */ = { + isa = PBXGroup; + children = ( + FDC4386A27B4E88F00C60D73 /* BatchRequestInfo.swift */, + FDC4386627B4E10E00C60D73 /* Capabilities.swift */, + FDC4385C27B4C18900C60D73 /* Room.swift */, + FDC4386427B4DE7600C60D73 /* RoomPollInfo.swift */, + FDC4385E27B4C4A200C60D73 /* PinnedMessage.swift */, + FDC4387727B5C35400C60D73 /* SendMessageRequest.swift */, + FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */, + FDC4386227B4D94E00C60D73 /* SOGSMessage.swift */, + FDC438C627BB6DF000C60D73 /* DirectMessage.swift */, + FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */, + FD83B9C827D0487A005E1583 /* SendDirectMessageResponse.swift */, + FDC438A327BB107F00C60D73 /* UserBanRequest.swift */, + FDC438A527BB113A00C60D73 /* UserUnbanRequest.swift */, + FDC438A927BB12BB00C60D73 /* UserModeratorRequest.swift */, + ); + path = Models; + sourceTree = ""; + }; + FDC4382D27B383A600C60D73 /* Models */ = { + isa = PBXGroup; + children = ( + FDC4382E27B383AF00C60D73 /* PushServerResponse.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 */, + FD83B9CD27D17A04005E1583 /* Request.swift */, + ); + path = "Common Networking"; + sourceTree = ""; + }; + FDC4385527B484AE00C60D73 /* Models */ = { + isa = PBXGroup; + children = ( + FDC4387127B5BB3B00C60D73 /* FileUploadResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */ = { + isa = PBXGroup; + children = ( + FDC4389B27BA01E300C60D73 /* _TestUtilities */, + FD3C905D27E410DB00CD579F /* Common Networking */, + FD3C906527E416A200CD579F /* Contacts */, + FD3C906B27E43C2400CD579F /* Sending & Receiving */, + FDC4389827BA001800C60D73 /* Open Groups */, + FD3C906827E417B100CD579F /* Utilities */, + ); + path = SessionMessagingKitTests; + sourceTree = ""; + }; + FDC4389827BA001800C60D73 /* Open Groups */ = { + isa = PBXGroup; + children = ( + FD83B9C127CF33EE005E1583 /* Models */, + FDC2909227D710A9005DAE71 /* Types */, + FDC4389927BA002500C60D73 /* OpenGroupAPISpec.swift */, + FDC2909D27D85751005DAE71 /* OpenGroupManagerSpec.swift */, + ); + path = "Open Groups"; + sourceTree = ""; + }; + FDC4389B27BA01E300C60D73 /* _TestUtilities */ = { + isa = PBXGroup; + children = ( + FDC438BC27BB2AB400C60D73 /* Mockable.swift */, + FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */, + FD859EF327C2F49200510D0C /* MockSodium.swift */, + FD3C906E27E43E8700CD579F /* MockBox.swift */, + FD859EF927C2F5C500510D0C /* MockGenericHash.swift */, + FD859EF527C2F52C00510D0C /* MockSign.swift */, + FD859EF727C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift */, + FD859EFB27C2F60700510D0C /* MockEd25519.swift */, + FD078E5927E29F09000769AF /* MockNonce16Generator.swift */, + FD078E5B27E29F78000769AF /* MockNonce24Generator.swift */, + FD83B9D127D59495005E1583 /* MockUserDefaults.swift */, + FD078E4C27E17156000769AF /* MockOGMCache.swift */, + FDC290B227DFF9F5005DAE71 /* TestOnionRequestAPI.swift */, + FD078E4E27E175F1000769AF /* DependencyExtensions.swift */, + FD078E5127E1760A000769AF /* OGMDependencyExtensions.swift */, + ); + path = _TestUtilities; + sourceTree = ""; + }; + FDC438AF27BB158500C60D73 /* Models */ = { + isa = PBXGroup; + children = ( + FD17D7D127F5797A00122BE0 /* SnodeAPIEndpoint.swift */, + FD77289F284EF5810018502F /* SnodeAPIError.swift */, + FDC438B827BB161E00C60D73 /* OnionRequestAPIVersion.swift */, + FD17D7D727F658E200122BE0 /* OnionRequestAPIDestination.swift */, + FD17D7D327F6584600122BE0 /* OnionRequestAPIError.swift */, + FDC438B027BB159600C60D73 /* RequestInfo.swift */, + FDC438B227BB15B400C60D73 /* ResponseInfo.swift */, + FD09796827F6BEA700936362 /* SwarmSnode.swift */, + FD77289B284DDCE10018502F /* SnodePoolResponse.swift */, + FD17D7E027F67BD400122BE0 /* SnodeReceivedMessage.swift */, + ); + path = Models; + sourceTree = ""; + }; + FDCDB8EF2817ABCE00352A0C /* Utilities */ = { + isa = PBXGroup; + children = ( + FDCDB8F02817ABE600352A0C /* Optional+Utilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + FDE7214E287E50D50093DF33 /* Scripts */ = { + isa = PBXGroup; + children = ( + FDE7214F287E50D50093DF33 /* ProtoWrappers.py */, + FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */, + ); + path = Scripts; + sourceTree = ""; + }; + FDF0B7452804F0A8004C14C5 /* Types */ = { + isa = PBXGroup; + children = ( + FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */, + FDA8EAFD280E8B78002B68E5 /* FailedMessageSendsJob.swift */, + FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */, + FD6A7A6A2818C17C00035AC1 /* UpdateProfilePictureJob.swift */, + FD6A7A682818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift */, + FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */, + C352A2FE25574B6300338F3E /* MessageSendJob.swift */, + C352A31225574F5200338F3E /* MessageReceiveJob.swift */, + C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */, + FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */, + C352A348255781F400338F3E /* AttachmentDownloadJob.swift */, + C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */, + ); + path = Types; + sourceTree = ""; + }; + FDF0B7562807F35E004C14C5 /* Errors */ = { + isa = PBXGroup; + children = ( + FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */, + FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */, + FD09C5EB282B8F17000CE219 /* AttachmentError.swift */, + ); + 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 */ @@ -3663,49 +3928,28 @@ 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 */, C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */, - C33FDC2C255A581F00E217F9 /* OWSFailedAttachmentDownloadsJob.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 */, - 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 */, C38EF366255B6DCC007E1867 /* ScreenLockViewController.h in Headers */, - 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 */, - C38EF290255B6D86007E1867 /* AppSetup.h in Headers */, C38EF367255B6DCC007E1867 /* OWSTableViewController.h in Headers */, C38EF246255B6D67007E1867 /* UIFont+OWS.h in Headers */, 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; }; @@ -3721,17 +3965,12 @@ 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 */, - C352A3B72557B6ED00338F3E /* TSRequest.h in Headers */, - C32C5A36256DB856003C73A2 /* LKGroupUtilities.h in Headers */, C3D9E379256760340040E4F3 /* MIMETypeUtil.h in Headers */, 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 */, @@ -3746,55 +3985,12 @@ 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 */, - 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 */, - C32C5C24256DCB30003C73A2 /* NotificationsProtocol.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 */, + FD716E732850647900C96BF4 /* NSData+messagePadding.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 */, - 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 */, - 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3805,7 +4001,7 @@ isa = PBXNativeTarget; buildConfigurationList = 453518761FC635DD00210559 /* Build configuration list for PBXNativeTarget "SessionShareExtension" */; buildPhases = ( - 16FA77C536F8C08C5046FA6B /* [CP] Check Pods Manifest.lock */, + BE7147D3A1A96E25B7541FD7 /* [CP] Check Pods Manifest.lock */, 453518641FC635DD00210559 /* Sources */, 453518651FC635DD00210559 /* Frameworks */, 453518661FC635DD00210559 /* Resources */, @@ -3828,7 +4024,7 @@ isa = PBXNativeTarget; buildConfigurationList = 7BC01A45241F40AB00BC7C55 /* Build configuration list for PBXNativeTarget "SessionNotificationServiceExtension" */; buildPhases = ( - 4B4609DACEC6E462A2394D2F /* [CP] Check Pods Manifest.lock */, + D08C2A7507F29F5B2E8686D0 /* [CP] Check Pods Manifest.lock */, 7BC01A37241F40AB00BC7C55 /* Sources */, 7BC01A38241F40AB00BC7C55 /* Frameworks */, 7BC01A39241F40AB00BC7C55 /* Resources */, @@ -3850,7 +4046,7 @@ isa = PBXNativeTarget; buildConfigurationList = C331FF262558F9D400070591 /* Build configuration list for PBXNativeTarget "SessionUIKit" */; buildPhases = ( - E185AC3DC0F55CFE87DEC852 /* [CP] Check Pods Manifest.lock */, + 3EB40416B01456AA719B686B /* [CP] Check Pods Manifest.lock */, C331FF162558F9D300070591 /* Headers */, C331FF172558F9D300070591 /* Sources */, C331FF182558F9D300070591 /* Frameworks */, @@ -3869,7 +4065,7 @@ isa = PBXNativeTarget; buildConfigurationList = C33FD9B6255A548A00E217F9 /* Build configuration list for PBXNativeTarget "SignalUtilitiesKit" */; buildPhases = ( - 7E2D14F857C70F98DED3B8E9 /* [CP] Check Pods Manifest.lock */, + B3755B8F0046FD78A100ADF5 /* [CP] Check Pods Manifest.lock */, C33FD9A6255A548A00E217F9 /* Headers */, C33FD9A7255A548A00E217F9 /* Sources */, C33FD9A8255A548A00E217F9 /* Frameworks */, @@ -3888,7 +4084,7 @@ isa = PBXNativeTarget; buildConfigurationList = C3C2A5AA255385C100C340D1 /* Build configuration list for PBXNativeTarget "SessionSnodeKit" */; buildPhases = ( - B19B891E99B1507CAC8AAD19 /* [CP] Check Pods Manifest.lock */, + 0DAEB0CC30945175049E8D88 /* [CP] Check Pods Manifest.lock */, C3C2A59A255385C100C340D1 /* Headers */, C3C2A59B255385C100C340D1 /* Sources */, C3C2A59C255385C100C340D1 /* Frameworks */, @@ -3907,7 +4103,7 @@ isa = PBXNativeTarget; buildConfigurationList = C3C2A684255388CC00C340D1 /* Build configuration list for PBXNativeTarget "SessionUtilitiesKit" */; buildPhases = ( - 83DABC75697364620557C68B /* [CP] Check Pods Manifest.lock */, + AF68D547A722E10BF230F662 /* [CP] Check Pods Manifest.lock */, C3C2A674255388CC00C340D1 /* Headers */, C3C2A675255388CC00C340D1 /* Sources */, C3C2A676255388CC00C340D1 /* Frameworks */, @@ -3926,7 +4122,7 @@ isa = PBXNativeTarget; buildConfigurationList = C3C2A6F925539DE700C340D1 /* Build configuration list for PBXNativeTarget "SessionMessagingKit" */; buildPhases = ( - 5BEA71AEF5E31390FEFA2E99 /* [CP] Check Pods Manifest.lock */, + 216EE1F2AA1CC3CBD9416785 /* [CP] Check Pods Manifest.lock */, C3C2A6EB25539DE700C340D1 /* Headers */, C3C2A6EC25539DE700C340D1 /* Sources */, C3C2A6ED25539DE700C340D1 /* Frameworks */, @@ -3946,13 +4142,14 @@ isa = PBXNativeTarget; buildConfigurationList = D221A0BC169C9E5F00537ABF /* Build configuration list for PBXNativeTarget "Session" */; buildPhases = ( - 1460156AE01E0DB0949D61FE /* [CP] Check Pods Manifest.lock */, + 0401967CF3320CC84B175A3B /* [CP] Check Pods Manifest.lock */, + FDE7214D287E50820093DF33 /* Lint Localizable.strings */, D221A085169C9E5E00537ABF /* Sources */, D221A086169C9E5E00537ABF /* Frameworks */, D221A087169C9E5E00537ABF /* Resources */, - 59C9DBA462715B5C999FFB02 /* [CP] Embed Pods Frameworks */, 453518771FC635DD00210559 /* Embed App Extensions */, 4535189F1FC63DBF00210559 /* Embed Frameworks */, + 6E75B456D9C7705F6FD9C9D4 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -3970,6 +4167,48 @@ productReference = D221A089169C9E5E00537ABF /* Session.app */; productType = "com.apple.product-type.application"; }; + FD83B9AE27CF200A005E1583 /* SessionUtilitiesKitTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FD83B9B627CF200A005E1583 /* Build configuration list for PBXNativeTarget "SessionUtilitiesKitTests" */; + buildPhases = ( + 73A908879E7A14832EF8B14A /* [CP] Check Pods Manifest.lock */, + FD83B9AB27CF200A005E1583 /* Sources */, + FD83B9AC27CF200A005E1583 /* Frameworks */, + FD83B9AD27CF200A005E1583 /* Resources */, + 4124998F864C780E396BCF56 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + FD83B9B527CF200A005E1583 /* PBXTargetDependency */, + FDCDB8EE28179EB200352A0C /* 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" */; + buildPhases = ( + F30F419364B564D672BA0940 /* [CP] Check Pods Manifest.lock */, + FDC4388A27B9FFC700C60D73 /* Sources */, + FDC4388B27B9FFC700C60D73 /* Frameworks */, + FDC4388C27B9FFC700C60D73 /* Resources */, + BE3DD274CEE45348F1CA328A /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + FDC4389427B9FFC700C60D73 /* PBXTargetDependency */, + FDCDB8EC28179EAF00352A0C /* PBXTargetDependency */, + ); + name = SessionMessagingKitTests; + productName = SessionMessagingKitTests; + productReference = FDC4388E27B9FFC700C60D73 /* SessionMessagingKitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -3977,9 +4216,9 @@ isa = PBXProject; attributes = { DefaultBuildSystemTypeForWorkspace = Original; - LastSwiftUpdateCheck = 1130; + LastSwiftUpdateCheck = 1320; LastTestingUpgradeCheck = 0600; - LastUpgradeCheck = 1020; + LastUpgradeCheck = 1320; ORGANIZATIONNAME = "Rangeproof Pty Ltd"; TargetAttributes = { 453518671FC635DD00210559 = { @@ -4067,6 +4306,12 @@ }; }; }; + FD83B9AE27CF200A005E1583 = { + CreatedOnToolsVersion = 13.2.1; + }; + FDC4388D27B9FFC700C60D73 = { + CreatedOnToolsVersion = 13.2.1; + }; }; }; buildConfigurationList = D221A083169C9E5E00537ABF /* Build configuration list for PBXProject "Session" */; @@ -4111,6 +4356,8 @@ C3C2A6EF25539DE700C340D1 /* SessionMessagingKit */, C3C2A59E255385C100C340D1 /* SessionSnodeKit */, C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */, + FDC4388D27B9FFC700C60D73 /* SessionMessagingKitTests */, + FD83B9AE27CF200A005E1583 /* SessionUtilitiesKitTests */, ); }; /* End PBXProject section */ @@ -4182,7 +4429,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 */, @@ -4237,19 +4483,37 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FD83B9AD27CF200A005E1583 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FDC4388C27B9FFC700C60D73 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 1460156AE01E0DB0949D61FE /* [CP] Check Pods Manifest.lock */ = { + 0401967CF3320CC84B175A3B /* [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-Session-checkManifestLockResult.txt", ); @@ -4258,130 +4522,7 @@ 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; }; - 16FA77C536F8C08C5046FA6B /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension-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; - }; - 4B4609DACEC6E462A2394D2F /* [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-SessionNotificationServiceExtension-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; - }; - 59C9DBA462715B5C999FFB02 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 5BEA71AEF5E31390FEFA2E99 /* [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-SessionMessagingKit-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; - }; - 7E2D14F857C70F98DED3B8E9 /* [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-SignalUtilitiesKit-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; - }; - 83DABC75697364620557C68B /* [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-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 */ = { + 0DAEB0CC30945175049E8D88 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -4403,7 +4544,29 @@ 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; }; - E185AC3DC0F55CFE87DEC852 /* [CP] Check Pods Manifest.lock */ = { + 216EE1F2AA1CC3CBD9416785 /* [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-SessionMessagingKit-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; + }; + 3EB40416B01456AA719B686B /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -4425,6 +4588,208 @@ 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; }; + 4124998F864C780E396BCF56 /* [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; + }; + 6E75B456D9C7705F6FD9C9D4 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 73A908879E7A14832EF8B14A /* [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; + }; + AF68D547A722E10BF230F662 /* [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-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; + }; + B3755B8F0046FD78A100ADF5 /* [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-SignalUtilitiesKit-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; + }; + BE3DD274CEE45348F1CA328A /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${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-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-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + BE7147D3A1A96E25B7541FD7 /* [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-SessionShareExtension-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; + }; + D08C2A7507F29F5B2E8686D0 /* [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-SessionNotificationServiceExtension-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; + }; + F30F419364B564D672BA0940 /* [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-SessionMessagingKit-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; + }; + 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 */ @@ -4432,6 +4797,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */, B817AD9A26436593009DF825 /* SimplifiedConversationCell.swift in Sources */, C3ADC66126426688005F1414 /* ShareVC.swift in Sources */, 7BAF54D427ACCF01003D12F8 /* SAEScreenLockViewController.swift in Sources */, @@ -4477,20 +4843,14 @@ 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 */, C38EF3C3255B6DE7007E1867 /* ImageEditorTextItem.swift in Sources */, - 7BD477AE27F526E3004E2822 /* 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 */, - C38EF39B255B6DDA007E1867 /* ThreadViewModel.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 */, @@ -4507,25 +4867,18 @@ C33FDCD1255A582000E217F9 /* FunctionalUtil.m in Sources */, C38EF402255B6DF7007E1867 /* CommonStrings.swift in Sources */, 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 */, 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 */, C38EF407255B6DF7007E1867 /* Toast.swift in Sources */, C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */, - C38EF409255B6DF7007E1867 /* ContactTableViewCell.m 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 */, @@ -4534,19 +4887,14 @@ 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 */, 7BAF54DC27ACD12B003D12F8 /* 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 */, - C33FDD13255A582000E217F9 /* OWSFailedAttachmentDownloadsJob.m 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 */, @@ -4557,7 +4905,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 */, @@ -4567,28 +4914,16 @@ 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 */, 7BD477B027F526FF004E2822 /* BlockListUIUtils.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 */, - 7BD477AC27F15F41004E2822 /* OpenGroupServerIdLookupMigration.swift in Sources */, - C33FDCD3255A582000E217F9 /* GroupUtilities.swift in Sources */, - 7B93D07327CF19C800811CB6 /* 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 */, C38EF359255B6DCC007E1867 /* SheetViewController.swift in Sources */, @@ -4596,6 +4931,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 */, @@ -4609,21 +4945,36 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD77289C284DDCE10018502F /* SnodePoolResponse.swift in Sources */, + FD09796927F6BEA700936362 /* SwarmSnode.swift in Sources */, C3C2A5E02553860B00C340D1 /* Threading.swift in Sources */, C3C2A5BF255385EE00C340D1 /* SnodeMessage.swift in Sources */, + FD17D7E127F67BD400122BE0 /* SnodeReceivedMessage.swift in Sources */, + FDC438B127BB159600C60D73 /* RequestInfo.swift in Sources */, + FDC438B927BB161E00C60D73 /* OnionRequestAPIVersion.swift in Sources */, + FD7728A0284EF5810018502F /* SnodeAPIError.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 */, - C32C5CBE256DD282003C73A2 /* Storage+OnionRequests.swift in Sources */, + FD17D7AA27F41BF500122BE0 /* SnodeSet.swift in Sources */, + FD17D7A427F40F8100122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, C3C2A5DC2553860B00C340D1 /* Promise+Threading.swift in Sources */, C3C2A5C4255385EE00C340D1 /* OnionRequestAPI+Encryption.swift in Sources */, + FD17D7D227F5797A00122BE0 /* SnodeAPIEndpoint.swift in Sources */, C3C2A5DE2553860B00C340D1 /* String+Trimming.swift in Sources */, C3C2A5DB2553860B00C340D1 /* Promise+Hashing.swift in Sources */, C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, + FD17D7A727F41AF000122BE0 /* SSKLegacy.swift in Sources */, + FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */, C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, + FD17D7D827F658E200122BE0 /* OnionRequestAPIDestination.swift in Sources */, + FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */, + FD17D7B327F51E5B00122BE0 /* SSKSetting.swift in Sources */, + FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */, C3C2A5C3255385EE00C340D1 /* OnionRequestAPI.swift in Sources */, - C3C2A5C1255385EE00C340D1 /* Storage.swift in Sources */, + FD90040F2818AB6D00ABAAF6 /* GetSnodePoolJob.swift in Sources */, + FD17D7D427F6584600122BE0 /* OnionRequestAPIError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4631,42 +4982,62 @@ 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 */, C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */, C3D9E39B256763C20040E4F3 /* AppContext.m in Sources */, C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */, + FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */, + FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */, + FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, - 7BAF54DA27ACD0E3003D12F8 /* UITableView+ReusableView.swift in Sources */, - C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */, + FD09797B27FBB25900936362 /* Updatable.swift in Sources */, + FDCDB8F12817ABE600352A0C /* Optional+Utilities.swift in Sources */, 7B7CB192271508AD0079FF93 /* CallRingTonePlayer.swift 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 */, + FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */, + FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */, + FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */, + FDCDB8E42817819600352A0C /* (null) in Sources */, C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */, C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */, C3D9E43125676D3D0040E4F3 /* Configuration.swift in Sources */, C32C5DD2256DD9E5003C73A2 /* LRUCache.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 */, + 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 */, + 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 */, + FD848B9328420164000E298B /* UnicodeScalar+Utilities.swift in Sources */, + FD09796B27F6C67500936362 /* Failable.swift in Sources */, + FD705A92278D051200F16121 /* ReusableView.swift in Sources */, + FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */, + FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */, + FD17D7C527F5206300122BE0 /* ColumnDefinition+Utilities.swift in Sources */, + FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */, 7BAF54D827ACD0E3003D12F8 /* ReusableView.swift in Sources */, - C3D9E3C925676AF30040E4F3 /* TSYapDatabaseObject.m in Sources */, - C352A3A62557B60D00338F3E /* TSRequest.m in Sources */, - B8AE75A425A6C6A6001A84D2 /* Data+Trimming.swift in Sources */, + FD77289E284EF1C50018502F /* Sodium+Utilities.swift in Sources */, 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 */, + FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */, 7B0EFDEE274F598600FFAAE7 /* TimestampUtils.swift in Sources */, C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */, C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */, @@ -4674,24 +5045,37 @@ C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */, 7BD477A827EC39F5004E2822 /* Atomic.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, + FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, + FD5D201E27B0D87C00FEA984 /* SessionId.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 */, + FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */, B88FA7FB26114EA70049422F /* Hex.swift in Sources */, + FD7728962849E7E90018502F /* String+Utilities.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 */, - 7BAF54D927ACD0E3003D12F8 /* String+Localization.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 */, + 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 */, + FD9004162818B46700ABAAF6 /* JobRunnerError.swift in Sources */, + FD09797227FAA2F500936362 /* Optional+Utilities.swift in Sources */, + FD7162DB281B6C440060647B /* TypedTableAlias.swift in Sources */, + FDFD645B27F26D4600808CA1 /* Data+Utilities.swift in Sources */, C3A7219A2558C1660043A11F /* AnyPromise+Conversion.swift in Sources */, - C300A6322554B6D100555489 /* NSDate+Timestamp.mm in Sources */, + FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4699,152 +5083,166 @@ 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 */, - C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */, + FD09799927FFC1A300936362 /* Attachment.swift in Sources */, + FD245C5F2850662200B966DD /* OWSWindowManager.m in Sources */, C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, - 7B703747283CA919000DCF35 /* Storage+Calls.swift in Sources */, - C352A32F2557549C00338F3E /* NotifyPNServerJob.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 */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, B8B558FF26C4E05E00693325 /* WebRTCSession+MessageHandling.swift in Sources */, C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, - C32C5AAD256DBE8F003C73A2 /* TSInfoMessage.m in Sources */, - C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */, + FD245C58285065F700B966DD /* OpenGroupServerIdLookup.swift in Sources */, + FD245C5A2850660100B966DD /* LinkPreviewDraft.swift in Sources */, + FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */, + FD6A7A6B2818C17C00035AC1 /* UpdateProfilePictureJob.swift in Sources */, + FD716E6A2850327900C96BF4 /* EndCallMode.swift in Sources */, + FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */, + FD09799727FFA84A00936362 /* RecipientState.swift in Sources */, + FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */, + FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */, + FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */, + FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */, + FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */, + FD09797527FAB64300936362 /* ProfileManager.swift in Sources */, + FD245C57285065F100B966DD /* Poller.swift in Sources */, + FDA8EAFE280E8B78002B68E5 /* FailedMessageSendsJob.swift in Sources */, + FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */, + FDC4386927B4E6B800C60D73 /* String+Utlities.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 */, - C352A3932557883D00338F3E /* JobDelegate.swift in Sources */, - 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 */, + FDC4386527B4DE7600C60D73 /* RoomPollInfo.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 */, - 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 */, - 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 */, - B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */, - C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */, - C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */, - C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, - C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, - B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */, + FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */, + C3BBE0B52554F0E10050F1E3 /* (null) in Sources */, + FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */, + FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */, + FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, - B8856D34256F1192001CE70E /* Environment.m in Sources */, + FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */, + FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */, + FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, - C32C5AB1256DBE8F003C73A2 /* TSIncomingMessage.m in Sources */, - C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */, - C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */, - B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, - C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, - C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */, + FD09798727FD1B7800936362 /* GroupMember.swift in Sources */, + FDB4BBC92839BEF000B7C95D /* ProfileManagerError.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 */, - C3C2A7682553A3D900C340D1 /* VisibleMessage+Contact.swift in Sources */, + FDC6D6F32860607300B04575 /* Environment.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 */, - 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 */, - 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 */, - C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */, - C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */, - C32C5D9C256DD6DC003C73A2 /* OWSOutgoingReceiptManager.m in Sources */, - C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */, - C3DA9C0725AE7396008F7C7E /* ConfigurationMessage.swift in Sources */, - B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */, - C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */, - C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */, + FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */, + FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */, + FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */, + FD6A7A692818BE7300035AC1 /* RetrieveDefaultOpenGroupRoomsJob.swift in Sources */, + FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */, + FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */, + FDC4382F27B383AF00C60D73 /* PushServerResponse.swift in Sources */, + FDC4386327B4D94E00C60D73 /* SOGSMessage.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 */, + 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 */, 7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.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 */, - 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 */, + FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */, + C3D9E3BE25676AD70040E4F3 /* (null) 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 */, - C3DB66CC260AF1F3001EFC55 /* OpenGroupAPIV2+ObjC.swift in Sources */, - C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */, - C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */, - B88FA7B826045D100049422F /* OpenGroupAPIV2.swift in Sources */, - C32C5E97256DE0CB003C73A2 /* OWSPrimaryStorage.m in Sources */, - C32C5EB9256DE130003C73A2 /* OWSQuotedReplyModel+Conversion.swift in Sources */, + FD716E682850318E00C96BF4 /* CallMode.swift in Sources */, + FD09799527FE7B8E00936362 /* Interaction.swift in Sources */, + FD5C72FF284F0F120029977D /* MessageReceiver+ConfigurationMessages.swift in Sources */, + FDC4380927B31D4E00C60D73 /* OpenGroupAPIError.swift in Sources */, + FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */, + FDC438C927BB706500C60D73 /* SendDirectMessageRequest.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 */, - B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */, - C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */, - C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */, + FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, + FD09796E27FA6D0000936362 /* Contact.swift 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 */, + FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, + FD83B9CE27D17A04005E1583 /* Request.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 */, - C3DB66C3260ACCE6001EFC55 /* OpenGroupPollerV2.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 */, - B8856CA8256F0F42001CE70E /* OWSBackupFragment.m in Sources */, + FD245C642850664F00B966DD /* Threading.swift in Sources */, + FD848B8D283E0B26000E298B /* MessageInputTypes.swift in Sources */, C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */, - B87EF17126367CF800124B3C /* FileServerAPIV2.swift in Sources */, - C3A3A18A256E2092004D228D /* SignalRecipient.m in Sources */, + FD09799B27FFC82D00936362 /* Quote.swift in Sources */, + FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, - C32C5F11256DF79A003C73A2 /* SSKIncrementingIdFinder.swift in Sources */, + FD245C682850666300B966DD /* Message+Destination.swift in Sources */, + FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, + FD245C632850664600B966DD /* Configuration.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, - C32C5EEE256DF54E003C73A2 /* TSDatabaseView.m in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, - C3A3A13C256E1B27004D228D /* OWSMediaGalleryFinder.m in Sources */, - C32C5AAE256DBE8F003C73A2 /* TSInteraction.m in Sources */, - C32C5D23256DD4C0003C73A2 /* Mention.swift in Sources */, - C32C5FD6256E0346003C73A2 /* Notification+Thread.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 */, + FD09798127FCFEE800936362 /* SessionThread.swift in Sources */, + FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */, + FDF0B75E280AAF35004C14C5 /* Preferences.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 */, - C32C5C88256DD0D2003C73A2 /* Storage+Messaging.swift in Sources */, - C32C59C7256DB41F003C73A2 /* TSThread.m in Sources */, - C300A5B22554AF9800555489 /* VisibleMessage+Profile.swift in Sources */, - 7B0EFDF2275449AA00FFAAE7 /* TSInfoMessage+Calls.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 /* Job.swift in Sources */, - C32C5C01256DC9A0003C73A2 /* OWSIdentityManager.m in Sources */, - C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */, - C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */, - B82A0C3826B9098200C1BCE3 /* MessageInvalidator.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; }; @@ -4852,9 +5250,10 @@ 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 */, + FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, 7B13E1EB2811138200BD4F64 /* PrivacySettingsTableViewController.swift in Sources */, @@ -4862,10 +5261,10 @@ 7BAF54D027ACCEEC003D12F8 /* EmptySearchResultCell.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 */, B877E24626CA13BA0007970A /* CallVC+Camera.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 */, @@ -4876,23 +5275,24 @@ 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 */, - 451166C01FD86B98000739BA /* AccountManager.swift in Sources */, + FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */, 7B0EFDF62755CC5400FFAAE7 /* CallMissedTipsModal.swift in Sources */, C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */, 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 */, - 7BAF54CE27ACCEEC003D12F8 /* Storage+RecentSearchResults.swift 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 */, 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 */, @@ -4902,7 +5302,6 @@ C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */, C3548F0624456447009433A8 /* PNModeVC.swift in Sources */, B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */, - D221A09A169C9E5E00537ABF /* main.m in Sources */, 7BA68909272A27BE00EFC32F /* SessionCall.swift in Sources */, B835247925C38D880089A44F /* MessageCell.swift in Sources */, B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */, @@ -4912,24 +5311,27 @@ 4CC613362227A00400E21A3A /* ConversationSearch.swift in Sources */, B82149B825D60393009C0F2A /* BlockedModal.swift in Sources */, B82B408C239A068800A248E7 /* RegisterVC.swift in Sources */, - 346129991FD1E4DA00532771 /* SignalApp.m in Sources */, + FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */, + 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, + C331FFFE2558FF3B00070591 /* FullConversationCell.swift in Sources */, + FDFDE128282D05530098B17F /* MediaPresentationContext.swift in Sources */, + FDFDE124282D04F20098B17F /* MediaDismissAnimationController.swift in Sources */, 7BA6890F27325CE300EFC32F /* SessionCallManager+CXProvider.swift in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, - C331FFFE2558FF3B00070591 /* ConversationCell.swift in Sources */, + C331FFFE2558FF3B00070591 /* FullConversationCell.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 */, 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 */, 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 */, @@ -4938,11 +5340,9 @@ 34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */, B8B558F126C4BB0600693325 /* CameraManager.swift in Sources */, B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */, - 341341EF2187467A00192D59 /* ConversationViewModel.m in Sources */, 4C21D5D8223AC60F00EF8A77 /* PhotoCapture.swift in Sources */, C331FFF32558FF0300070591 /* PathStatusView.swift in Sources */, 7BFFB33C27D02F5800BEA04E /* CallPermissionRequestModal.swift in Sources */, - 4CC1ECFB211A553000CC13BE /* AppUpdateNag.swift in Sources */, B848A4C5269EAAA200617031 /* UserDetailsSheet.swift in Sources */, 34B6A903218B3F63007C4606 /* TypingIndicatorView.swift in Sources */, 7BAF54CF27ACCEEC003D12F8 /* GlobalSearchViewController.swift in Sources */, @@ -4951,12 +5351,11 @@ 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 */, 7BAADFCC27B0EF23007BCF92 /* CallVideoView.swift in Sources */, B8CCF63F23975CFB0091D419 /* JoinOpenGroupVC.swift in Sources */, - 34ABC0E421DD20C500ED9469 /* ConversationMessageMapping.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 */, @@ -4969,6 +5368,7 @@ 7BC707F227290ACB002817AD /* SessionCallManager.swift in Sources */, 7BAADFCE27B215FE007BCF92 /* UIView+Draggable.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 */, @@ -4979,7 +5379,7 @@ 7B1581E4271FC59D00848B49 /* CallModal.swift in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, - 34D1F0C01F8EC1760066283D /* MessageRecipientStatusUtils.swift in Sources */, + FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */, 7B7CB189270430D20079FF93 /* CallMessageView.swift in Sources */, C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, B82B4090239DD75000A248E7 /* RestoreVC.swift in Sources */, @@ -4989,10 +5389,9 @@ C31D1DE32521718E005D4DA8 /* UserSelectionVC.swift in Sources */, 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */, - 4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */, + FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */, B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */, B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */, - 76EB054018170B33006006FC /* AppDelegate.m in Sources */, 340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */, 7B0EFDF0275084AA00FFAAE7 /* CallMessageCell.swift in Sources */, 7B93D06A27CF173D00811CB6 /* MessageRequestsViewController.swift in Sources */, @@ -5000,7 +5399,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 */, 7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */, @@ -5008,30 +5406,93 @@ 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 */, 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 */, + FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */, 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 */, 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; + FD83B9AB27CF200A005E1583 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD078E4927E02576000769AF /* CommonMockedExtensions.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; + }; + FDC4388A27B9FFC700C60D73 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, + FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, + FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, + FDC290B327DFF9F5005DAE71 /* TestOnionRequestAPI.swift in Sources */, + FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, + FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.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 */, + FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, + FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, + FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */, + FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */, + 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 */, + 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 */, + FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, + 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 */, + FD078E5427E197CA000769AF /* OpenGroupManagerSpec.swift in Sources */, + FD3C906727E416AF00CD579F /* BlindedIdLookupSpec.swift in Sources */, + FD83B9D227D59495005E1583 /* MockUserDefaults.swift in Sources */, + FD078E5C27E29F78000769AF /* MockNonce24Generator.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -5120,6 +5581,27 @@ target = C33FD9AA255A548A00E217F9 /* SignalUtilitiesKit */; targetProxy = C3D90A7025773A44002C9DF5 /* PBXContainerItemProxy */; }; + FD83B9B527CF200A005E1583 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; + targetProxy = FD83B9B427CF200A005E1583 /* PBXContainerItemProxy */; + }; + FDC4389427B9FFC700C60D73 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + 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 */ @@ -5166,7 +5648,7 @@ /* Begin XCBuildConfiguration section */ 453518731FC635DD00210559 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0F1A8805563934A3324676D1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig */; + baseConfigurationReference = 96ED0C9B69379BE6FF4E9DA6 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -5204,7 +5686,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", @@ -5225,7 +5707,7 @@ }; 453518751FC635DD00210559 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0403B6AF17DFAD629A3AF862 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig */; + baseConfigurationReference = 5626DC0D5F62C1C2C64E4AFC /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -5282,7 +5764,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", @@ -5305,7 +5787,7 @@ }; 7BC01A43241F40AB00BC7C55 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F7A2E3D105D13A663129EA2C /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */; + baseConfigurationReference = 1A0882BF820F5B44969F91F1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -5341,7 +5823,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", @@ -5362,7 +5844,7 @@ }; 7BC01A44241F40AB00BC7C55 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 08EF72D6EB5CDC49C863781E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig */; + baseConfigurationReference = 245BF74EF6348E2D4125033F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -5420,7 +5902,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", @@ -5443,7 +5925,7 @@ }; C331FF242558F9D400070591 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C88965DE4F4EC4FC919BEC4E /* Pods-SessionUIKit.debug.xcconfig */; + baseConfigurationReference = EC5C23F9D234F558BE5E41DE /* Pods-SessionUIKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -5457,7 +5939,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"; @@ -5477,7 +5959,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", @@ -5500,7 +5982,7 @@ }; C331FF252558F9D400070591 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C1A746BC424B531D8ED478F6 /* Pods-SessionUIKit.app store release.xcconfig */; + baseConfigurationReference = 29CF8C79F41BF00B1C2E59A0 /* Pods-SessionUIKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -5526,7 +6008,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; @@ -5556,7 +6038,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", @@ -5581,7 +6063,7 @@ }; C33FD9B4255A548A00E217F9 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 826CF3AB370207485081AD78 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig */; + baseConfigurationReference = DAF57FAAF30631D0E99DA361 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -5596,7 +6078,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"; @@ -5624,7 +6106,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", @@ -5647,7 +6129,7 @@ }; C33FD9B5255A548A00E217F9 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 69ED39F18BDEC3C861B8044F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */; + baseConfigurationReference = 82099864FD91C9126A750313 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -5673,7 +6155,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; @@ -5711,7 +6193,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", @@ -5736,7 +6218,7 @@ }; C3C2A5A8255385C100C340D1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 292F8FB4C82D8FF94571D837 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig */; + baseConfigurationReference = E23C1E6B7E0C12BF4ACD9CBE /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -5751,7 +6233,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"; @@ -5771,7 +6253,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", @@ -5794,7 +6276,7 @@ }; C3C2A5A9255385C100C340D1 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = CA4942875292B7BD5C0C02A6 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig */; + baseConfigurationReference = 0E836037CC97CE5A47735596 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -5820,7 +6302,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; @@ -5850,7 +6332,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", @@ -5875,7 +6357,7 @@ }; C3C2A682255388CC00C340D1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F04906EA72326B6CF4FF859E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */; + baseConfigurationReference = F705826F79C4A591AB35D68F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -5890,7 +6372,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"; @@ -5910,7 +6392,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", @@ -5942,7 +6424,7 @@ }; C3C2A683255388CC00C340D1 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 698ED62BE96164824CE7CC18 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig */; + baseConfigurationReference = 2581AFACDDDC1404866D7B8C /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -5968,7 +6450,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; @@ -5998,7 +6480,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", @@ -6032,7 +6514,7 @@ }; C3C2A6FA25539DE700C340D1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 848B0C04B8211741A916EE49 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig */; + baseConfigurationReference = 506FA2159653FF9F446D97D1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -6047,7 +6529,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"; @@ -6067,7 +6549,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", @@ -6090,7 +6572,7 @@ }; C3C2A6FB25539DE700C340D1 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1758FCA85C98360EBA3949CF /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig */; + baseConfigurationReference = FF694C71BE4B41B6AFD252A0 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -6116,7 +6598,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; @@ -6146,7 +6628,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", @@ -6188,6 +6670,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; @@ -6199,7 +6682,7 @@ ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/ThirdParty/Carthage/Build/iOS"; + FRAMEWORK_SEARCH_PATHS = ""; GCC_NO_COMMON_BLOCKS = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", @@ -6264,6 +6747,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; @@ -6274,7 +6758,7 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - FRAMEWORK_SEARCH_PATHS = "$(PROJECT_DIR)/ThirdParty/Carthage/Build/iOS"; + FRAMEWORK_SEARCH_PATHS = ""; GCC_NO_COMMON_BLOCKS = YES; GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; @@ -6320,9 +6804,9 @@ }; D221A0BD169C9E5F00537ABF /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 37C61C4A1D3A11D3FC03FE22 /* Pods-GlobalDependencies-Session.debug.xcconfig */; + 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; @@ -6334,12 +6818,11 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 348; + CURRENT_PROJECT_VERSION = 366; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)", - "$(PROJECT_DIR)/Dependencies", ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -6364,7 +6847,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", @@ -6374,7 +6857,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 1.13.0; + MARKETING_VERSION = 2.0.0; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -6395,9 +6878,9 @@ }; D221A0BE169C9E5F00537ABF /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 56298B3B5A12567B30355B67 /* Pods-GlobalDependencies-Session.app store release.xcconfig */; + 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; @@ -6407,12 +6890,11 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 348; + CURRENT_PROJECT_VERSION = 366; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)", - "$(PROJECT_DIR)/Dependencies", ); GCC_OPTIMIZATION_LEVEL = 3; GCC_PRECOMPILE_PREFIX_HEADER = YES; @@ -6437,7 +6919,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", @@ -6447,7 +6929,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 1.13.0; + MARKETING_VERSION = 2.0.0; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; @@ -6463,6 +6945,218 @@ }; name = "App Store Release"; }; + FD83B9B727CF200A005E1583 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BE11AFA6FD8CAE894CABC28D /* 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 = "$(inherited)"; + 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 = 58A6BA91F634756FA0BEC9E5 /* 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 = "$(inherited)"; + 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 = D0CE0424239A1574F683D2D7 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-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 = "$(inherited)"; + 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 = 8E029A324780A800DE6B70B3 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-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 = "$(inherited)"; + 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 */ @@ -6547,6 +7241,24 @@ 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 = ( + 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..82d7881ad 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/Session.xcscheme @@ -1,6 +1,6 @@ - - - - + shouldUseLaunchSchemeArgsEnv = "NO" + codeCoverageEnabled = "YES" + onlyGenerateCoverageForSpecifiedTargets = "YES"> + + + + + + + + + + + + + + + + + + + skipped = "NO" + parallelizable = "YES" + testExecutionOrdering = "random"> + skipped = "NO" + parallelizable = "YES" + testExecutionOrdering = "random"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + BlueprintIdentifier = "FD83B9AE27CF200A005E1583" + BuildableName = "SessionUtilitiesKitTests.xctest" + BlueprintName = "SessionUtilitiesKitTests" + ReferencedContainer = "container:Session.xcodeproj"> diff --git a/Session.xcodeproj/xcshareddata/xcschemes/SessionMessagingKit.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/SessionMessagingKit.xcscheme new file mode 100644 index 000000000..00369579b --- /dev/null +++ b/Session.xcodeproj/xcshareddata/xcschemes/SessionMessagingKit.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session.xcodeproj/xcshareddata/xcschemes/SessionNotificationServiceExtension.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/SessionNotificationServiceExtension.xcscheme index 6f27e32c8..78326ab79 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/SessionNotificationServiceExtension.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/SessionNotificationServiceExtension.xcscheme @@ -1,6 +1,6 @@ + + + + diff --git a/Session.xcodeproj/xcshareddata/xcschemes/SessionShareExtension.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/SessionShareExtension.xcscheme index 7ab99da50..696978717 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/SessionShareExtension.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/SessionShareExtension.xcscheme @@ -1,6 +1,6 @@ + + + + diff --git a/Session.xcodeproj/xcshareddata/xcschemes/SessionUtilitiesKit.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/SessionUtilitiesKit.xcscheme new file mode 100644 index 000000000..3497ab98f --- /dev/null +++ b/Session.xcodeproj/xcshareddata/xcschemes/SessionUtilitiesKit.xcscheme @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Session.xcodeproj/xcshareddata/xcschemes/SignalUtilitiesKit.xcscheme b/Session.xcodeproj/xcshareddata/xcschemes/SignalUtilitiesKit.xcscheme index 2781eb1b3..9b86bb65a 100644 --- a/Session.xcodeproj/xcshareddata/xcschemes/SignalUtilitiesKit.xcscheme +++ b/Session.xcodeproj/xcshareddata/xcschemes/SignalUtilitiesKit.xcscheme @@ -1,6 +1,6 @@ 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,114 @@ 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) + .map { UIImage(data: $0) } + .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 } + guard case .answer = mode else { + SessionCallManager.reportFakeCall(info: "Call not in answer mode") + 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 + Storage.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 +257,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) + + Storage.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 + + Storage.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 +342,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 +389,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 == true else { reconnectTimer = Timer.scheduledTimerOnMainThread(withTimeInterval: 5, repeats: false) { _ in self.tryToReconnect() } + return } + + let sessionId: String = self.sessionId + let webRTCSession: WebRTCSession = self.webRTCSession + + Storage.shared + .read { 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..6ac7c49cd 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 } + + Storage.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..643268bc1 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,9 +39,8 @@ public final class SessionCallManager: NSObject { } } - class func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration { - let localizedName = NSLocalizedString("APPLICATION_NAME", comment: "Name of application") - let providerConfiguration = CXProviderConfiguration(localizedName: localizedName) + static func buildProviderConfiguration(useSystemCallLog: Bool) -> CXProviderConfiguration { + let providerConfiguration = CXProviderConfiguration(localizedName: "Session") providerConfiguration.supportsVideo = true providerConfiguration.maximumCallGroups = 1 providerConfiguration.maximumCallsPerCallGroup = 1 @@ -47,30 +52,47 @@ 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 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(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 +104,61 @@ 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") + } + else { + SessionCallManager.reportFakeCall(info: "No CXProvider instance") + 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 +172,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 = Storage.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 a7617f577..cfcca7798 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -1,63 +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) } @@ -67,27 +81,62 @@ 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 Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + + let threadId: String = self.threadId + + Storage.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! + handleMembersChanged() } private func setUpViewHierarchy() { // Group name container - groupNameLabel.text = thread.groupModel.groupName + groupNameLabel.text = name + let groupNameContainer = UIView() groupNameContainer.addSubview(groupNameLabel) groupNameLabel.pin(to: groupNameContainer) @@ -95,6 +144,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) @@ -102,19 +152,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(ContactUtilities.getAllContacts()).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 @@ -122,8 +174,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), @@ -137,6 +191,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 @@ -152,41 +207,49 @@ 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) + self?.handleMembersChanged() } 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 @@ -196,21 +259,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 } @@ -222,93 +289,163 @@ 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 Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + + let userSelectionVC: UserSelectionVC = UserSelectionVC( + with: title, + excluding: membersAndZombies + .map { $0.profileId } + .asSet() + ) { [weak self] selectedUserIds in + Storage.shared.read { [weak self] db in + 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 + 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(ContactUtilities.getAllContacts()).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) + self?.handleMembersChanged() } - 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 + Storage.shared + .writeAsync { 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 } - MentionsManager.populateUserPublicKeyCacheIfNeeded(for: self.thread.uniqueId!) - 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 6b31ff423..bd51ba8c2 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -1,11 +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? { @@ -14,107 +19,127 @@ private final class TableView : UITableView { } } -final class NewClosedGroupVC : BaseVC, UITableViewDataSource, UITableViewDelegate, TableViewTouchDelegate, UITextFieldDelegate, UIScrollViewDelegate { - private let contacts = ContactUtilities.getAllContacts() +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! } @@ -135,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() { @@ -169,28 +196,34 @@ 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.done(on: DispatchQueue.main) { thread in - MessageSender.syncConfiguration(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 - 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) - } + Storage.shared + .writeAsync { db in + try MessageSender.createClosedGroup(db, name: name, members: selectedContacts) + } + .done(on: DispatchQueue.main) { thread in + Storage.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() } } @objc private func createNewDM() { presentingViewController?.dismiss(animated: true, completion: nil) - SignalApp.shared().homeViewController!.createNewDM() + + SessionApp.homeViewController.wrappedValue?.createNewDM() } } diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index d7eaaae65..13b3b8e0c 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -1,99 +1,156 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionMessagingKit extension ContextMenuVC { - struct Action { - let icon: UIImage + let icon: UIImage? let title: String + let isDismissAction: Bool 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(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_reply"), + title: "context_menu_reply".localized(), + isDismissAction: false + ) { delegate?.reply(cellViewModel) } } - 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(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_copy"), + title: "copy".localized(), + isDismissAction: false + ) { delegate?.copy(cellViewModel) } } - 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(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_copy"), + title: "vc_conversation_settings_copy_session_id_button_title".localized(), + isDismissAction: false + ) { delegate?.copySessionID(cellViewModel) } } - 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(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_trash"), + title: "TXT_DELETE_TITLE".localized(), + isDismissAction: false + ) { delegate?.delete(cellViewModel) } } - 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(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_download"), + title: "context_menu_save".localized(), + isDismissAction: false + ) { delegate?.save(cellViewModel) } } - 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(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_block"), + title: "context_menu_ban_user".localized(), + isDismissAction: false + ) { delegate?.ban(cellViewModel) } } - 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(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_block"), + 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 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 cellViewModel: MessageViewModel, currentUserIsOpenGroupModerator: Bool, delegate: ContextMenuActionDelegate?) -> [Action]? { + // No context items for info messages + guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else { + return nil } + + let canReply: Bool = ( + cellViewModel.variant != .standardOutgoing || ( + cellViewModel.state != .failed && + cellViewModel.state != .sending + ) + ) + let canCopy: Bool = ( + cellViewModel.cellType == .textOnlyMessage || ( + ( + cellViewModel.cellType == .genericAttachment || + cellViewModel.cellType == .mediaMessage + ) && + (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 = ( + cellViewModel.cellType == .mediaMessage && + (cellViewModel.attachments ?? []) + .filter { attachment in + attachment.isValid && + attachment.isVisualMedia && ( + attachment.state == .downloaded || + attachment.state == .uploaded + ) + }.isEmpty == false + ) + let canCopySessionId: Bool = ( + cellViewModel.variant == .standardIncoming && + cellViewModel.threadVariant != .openGroup + ) + let canDelete: Bool = ( + cellViewModel.threadVariant != .openGroup || + currentUserIsOpenGroupModerator + ) + let canBan: Bool = ( + cellViewModel.threadVariant == .openGroup && + currentUserIsOpenGroupModerator + ) + + let generatedActions: [Action] = [ + (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 } + + guard !generatedActions.isEmpty else { return [] } + + return generatedActions.appending(Action.dismiss(delegate)) } } -// 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(_ 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) func contextMenuDismissed() } 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 376022914..d7d9ed68a 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -1,43 +1,60 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class ContextMenuVC : UIViewController { +import UIKit +import SessionUIKit +import SessionMessagingKit + +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 cellViewModel: MessageViewModel + 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 = cellViewModel.dateForUI { + result.text = dateForUI.formattedForDisplay + } + 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, + cellViewModel: MessageViewModel, + actions: [Action], + dismiss: @escaping () -> Void + ) { self.snapshot = snapshot - self.viewItem = viewItem self.frame = frame - self.delegate = delegate + self.cellViewModel = cellViewModel + self.actions = actions self.dismiss = dismiss + super.init(nibName: nil, bundle: nil) } @@ -48,33 +65,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 cellViewModel.variant == .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 +108,35 @@ 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 + .filter { !$0.isDismissAction } + .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 + // 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 { 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 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 } + // Tap gesture let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) view.addGestureRecognizer(mainTapGestureRecognizer) @@ -108,31 +144,43 @@ 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() - self.delegate?.contextMenuDismissed() - }) + UIView.animate( + withDuration: 0.25, + animations: { [weak self] in + self?.blurView.effect = nil + self?.menuView.alpha = 0 + self?.snapshot.alpha = 0 + self?.timestampLabel.alpha = 0 + }, + completion: { [weak self] _ in + self?.dismiss() + self?.actions.first(where: { $0.isDismissAction })?.work() + } + ) } } 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/ConversationMessageMapping.swift b/Session/Conversations/ConversationMessageMapping.swift deleted file mode 100644 index e418561f5..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 { - SNLog("Could not load view.") - return nil - } - guard let group = group else { - SNLog("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 { - SNLog("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 { - SNLog("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 ba721dd4c..0f97b8644 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -1,143 +1,100 @@ -// -// 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 { +public class ConversationSearchController: NSObject { + public static let minimumSearchTextLength: UInt = 2 - @objc - func conversationSearchController(_ conversationSearchController: ConversationSearchController, - didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?) - - @objc - func conversationSearchController(_ conversationSearchController: ConversationSearchController, - didSelectMessageId: String) -} - -@objc -public class ConversationSearchController : NSObject { - - @objc - public static let kMinimumSearchTextLength: UInt = 2 - - @objc - public let uiSearchController = UISearchController(searchResultsController: nil) - - @objc + private let threadId: String public weak var delegate: ConversationSearchControllerDelegate? - - let thread: TSThread - - @objc + public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil) public let resultsBar: SearchResultsBar = SearchResultsBar() private var lastSearchText: String? // MARK: Initializer - @objc - required public init(thread: TSThread) { - self.thread = thread + public init(threadId: String) { + self.threadId = threadId + super.init() + + self.resultsBar.resultsBarDelegate = self + self.uiSearchController.delegate = self + self.uiSearchController.searchResultsUpdater = self - 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 + self.uiSearchController.hidesNavigationBarDuringPresentation = false + self.uiSearchController.searchBar.inputAccessoryView = resultsBar } } -extension ConversationSearchController : UISearchControllerDelegate { - +// MARK: - UISearchControllerDelegate + +extension ConversationSearchController: UISearchControllerDelegate { public func didPresentSearchController(_ searchController: UISearchController) { - Logger.verbose("") delegate?.didPresentSearchController?(searchController) } public func didDismissSearchController(_ searchController: UISearchController) { - Logger.verbose("") delegate?.didDismissSearchController?(searchController) } } -extension ConversationSearchController : UISearchResultsUpdating { - - var dbSearcher: FullTextSearcher { - return FullTextSearcher.shared - } +// MARK: - UISearchResultsUpdating +extension ConversationSearchController: UISearchResultsUpdating { 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) - lastSearchText = searchText - - guard searchText.count >= ConversationSearchController.kMinimumSearchTextLength else { - lastSearchText = nil - self.resultsBar.updateResults(resultSet: nil) - self.delegate?.conversationSearchController(self, didUpdateSearchResults: nil) - return + + let threadId: String = self.threadId + let results: [Int64] = Storage.shared.read { db -> [Int64] in + try Interaction.idsForTermWithin( + threadId: threadId, + pattern: try SessionThreadViewModel.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, searchText == self.lastSearchText 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) } } -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 - } +// MARK: - SearchResultsBarDelegate - self.delegate?.conversationSearchController(self, didSelectMessageId: searchResult.messageId) +extension ConversationSearchController: SearchResultsBarDelegate { + func searchResultsBar( + _ searchResultsBar: SearchResultsBar, + setCurrentIndex currentIndex: Int, + results: [Int64] + ) { + guard let interactionId: Int64 = results[safe: currentIndex] else { return } + + self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId) } } -protocol SearchResultsBarDelegate : AnyObject { - - func searchResultsBar(_ searchResultsBar: SearchResultsBar, - setCurrentIndex currentIndex: Int, - resultSet: ConversationScreenSearchResultSet) +protocol SearchResultsBarDelegate: AnyObject { + func searchResultsBar( + _ searchResultsBar: SearchResultsBar, + setCurrentIndex currentIndex: Int, + results: [Int64] + ) } -public final class SearchResultsBar : UIView { - private var resultSet: ConversationScreenSearchResultSet? +public final class SearchResultsBar: UIView { + private var results: [Int64]? var currentIndex: Int? weak var resultsBarDelegate: SearchResultsBarDelegate? @@ -145,7 +102,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 @@ -169,6 +125,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() @@ -181,6 +145,7 @@ public final class SearchResultsBar : UIView { private func setUpViewHierarchy() { autoresizingMask = .flexibleHeight + // Background & blur let backgroundView = UIView() backgroundView.backgroundColor = isLightMode ? .white : .black @@ -190,18 +155,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 @@ -209,110 +178,116 @@ 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 + func updateResults(results: [Int64]?) { + currentIndex = { + guard let results: [Int64] = results, !results.isEmpty else { return nil } + + if let currentIndex: Int = currentIndex { + return max(0, min(currentIndex, results.count - 1)) } - } else { - currentIndex = nil - } + + return 0 + }() - 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 results: [Int64]?, searchText: String?) + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 19fda911b..caef86415 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1,117 +1,111 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit import CoreServices import Photos import PhotosUI +import Sodium import PromiseKit +import GRDB +import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit -extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuActionDelegate, ScrollToBottomButtonDelegate, - SendMediaNavDelegate, UIDocumentPickerDelegate, AttachmentApprovalViewControllerDelegate, GifPickerViewControllerDelegate, - ConversationTitleViewDelegate { +extension ConversationVC: + InputViewDelegate, + MessageCellDelegate, + ContextMenuActionDelegate, + ScrollToBottomButtonDelegate, + SendMediaNavDelegate, + UIDocumentPickerDelegate, + AttachmentApprovalViewControllerDelegate, + GifPickerViewControllerDelegate +{ + @objc func handleTitleViewTapped() { + // Don't take the user to settings for unapproved threads + guard viewModel.threadData.threadRequiresApproval == false else { return } - func handleTitleViewTapped() { - // 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()), - 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.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) + 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: Call + // MARK: - Call + @objc func startCall(_ sender: Any?) { guard SessionCall.isEnabled else { return } - if SSKPreferences.areCallsEnabled { - requestMicrophonePermissionIfNeeded { } - guard AVAudioSession.sharedInstance().recordPermission == .granted else { return } - guard let contactSessionID = (thread as? TSContactThread)?.contactSessionID() else { return } - guard AppEnvironment.shared.callManager.currentCall == nil else { return } - let call = SessionCall(for: contactSessionID, uuid: UUID().uuidString.lowercased(), mode: .offer, outgoing: true) - let callVC = CallVC(for: call) - callVC.conversationVC = self - self.inputAccessoryView?.isHidden = true - self.inputAccessoryView?.alpha = 0 - present(callVC, animated: true, completion: nil) - } else { + guard Storage.shared[.areCallsEnabled] else { let callPermissionRequestModal = CallPermissionRequestModal() self.navigationController?.present(callPermissionRequestModal, animated: true, completion: nil) + return } + + 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 = Storage.shared.read({ db in SessionCall(db, for: threadId, uuid: UUID().uuidString.lowercased(), mode: .offer, outgoing: true) }) else { + return + } + + let callVC = CallVC(for: call) + callVC.conversationVC = self + self.inputAccessoryView?.isHidden = true + self.inputAccessoryView?.alpha = 0 + + present(callVC, animated: true, completion: nil) } - // MARK: Blocking + // MARK: - Blocking + @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) - }, - completion: { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - } - ) - } - }) + self.showBlockedModalIfNeeded() } func showBlockedModalIfNeeded() -> Bool { - guard let thread = thread as? TSContactThread, thread.isBlocked() else { return false } + guard self.viewModel.threadData.threadIsBlocked == true else { return false } - let blockedModal = BlockedModal(publicKey: thread.contactSessionID()) + let blockedModal = BlockedModal(publicKey: viewModel.threadData.threadId) 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) } - 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 = "" + resetMentions() dismiss(animated: true) { } } @@ -120,17 +114,19 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) { - snInputView.text = newMessageText ?? "" + snInputView.text = (newMessageText ?? "") } - func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) { + // MARK: - AttachmentApprovalViewControllerDelegate + + 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) } - + scrollToBottom(isAnimated: false) - resetMentions() self.snInputView.text = "" + resetMentions() } func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { @@ -140,42 +136,24 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { snInputView.text = newMessageText ?? "" } + + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { + } - 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) + func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { } - - func handleLibraryButtonTapped() { - requestLibraryPermissionIfNeeded { [weak self] in - DispatchQueue.main.async { - let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst() - sendMediaNavController.sendMediaNavDelegate = self - sendMediaNavController.modalPresentationStyle = .fullScreen - self?.present(sendMediaNavController, animated: true, completion: nil) - } - } - } - + + // MARK: - ExpandingAttachmentsButtonDelegate + func handleGIFButtonTapped() { - let gifVC = GifPickerViewController(thread: thread) + let gifVC = GifPickerViewController() gifVC.delegate = self + let navController = OWSNavigationController(rootViewController: gifVC) navController.modalPresentationStyle = .fullScreen present(navController, animated: true) { } } - func gifPickerDidSelect(attachment: SignalAttachment) { - 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. @@ -185,6 +163,45 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc SNAppearance.switchToDocumentPickerAppearance() present(documentPickerVC, animated: true, completion: nil) } + + func handleLibraryButtonTapped() { + let threadId: String = self.viewModel.threadData.threadId + + requestLibraryPermissionIfNeeded { [weak self] in + DispatchQueue.main.async { + let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst( + threadId: threadId + ) + sendMediaNavController.sendMediaNavDelegate = self + sendMediaNavController.modalPresentationStyle = .fullScreen + self?.present(sendMediaNavController, animated: true, completion: nil) + } + } + } + + 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(threadId: self.viewModel.threadData.threadId) + sendMediaNavController.sendMediaNavDelegate = self + sendMediaNavController.modalPresentationStyle = .fullScreen + + present(sendMediaNavController, animated: true, completion: nil) + } + + // MARK: - GifPickerViewControllerDelegate + + func gifPickerDidSelect(attachment: SignalAttachment) { + showAttachmentApprovalDialog(for: [ attachment ]) + } + + // MARK: - UIDocumentPickerDelegate func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { SNAppearance.switchToSessionAppearance() // Switch back to the correct appearance @@ -193,44 +210,59 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc 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 presentAlert(alert) } - 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 ]) } func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) { - let navController = AttachmentApprovalViewController.wrappedInNavController(attachments: attachments, approvalDelegate: self) + let navController = AttachmentApprovalViewController.wrappedInNavController( + threadId: self.viewModel.threadData.threadId, + attachments: attachments, + approvalDelegate: self + ) + present(navController, animated: true, completion: nil) } @@ -238,34 +270,48 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc 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 + // 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 { + + 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 @@ -274,59 +320,95 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc 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) - + // 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' // flags appropriately - let oldThreadShouldBeVisible: Bool = thread.shouldBeVisible - let linkPreviewDraft = snInputView.linkPreviewInfo?.draft - let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) + 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 - let promise: Promise = self.approveMessageRequestIfNeeded( - for: self.thread, + // If this was a message request then approve it + approveMessageRequestIfNeeded( + for: threadId, + threadVariant: self.viewModel.threadData.threadVariant, 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) - } - ) - - Storage.shared.write { transaction in - MessageSender.send(message, with: [], in: thread, using: transaction as! YapDatabaseReadWriteTransaction) + + // Send the message + Storage.shared.writeAsync( + updates: { [weak self] 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() - }) - } - - // Show an error indicating that approving the thread failed - promise.catch(on: DispatchQueue.main) { [weak self] _ in - 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() + } + ) } func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) { @@ -338,94 +420,90 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc } } - 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 oldThreadShouldBeVisible: Bool = thread.shouldBeVisible - let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread) - - let promise: Promise = self.approveMessageRequestIfNeeded( - for: self.thread, + let threadId: String = self.viewModel.threadData.threadId + 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, - 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 - }, - 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 - self?.scrollToBottom(isAnimated: false) - }) - self?.handleMessageSent() - - // Attachment successfully sent - dismiss the screen + + // Send the message + Storage.shared.writeAsync( + updates: { [weak self] 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?() } - ) - } - - // Show an error indicating that approving the thread failed - promise.catch(on: DispatchQueue.main) { [weak self] _ in - 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() + } + ) } 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? = Storage.shared.getContact(with: contactThread.contactSessionID()) + DispatchQueue.main.async { [weak self] in + self?.snInputView.text = "" + self?.snInputView.quoteDraftInfo = nil - // 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 - ) + self?.resetMentions() } - - self.markAllAsRead() - if Environment.shared.preferences.soundInForeground() { - let soundID = OWSSounds.systemSoundID(for: .messageSent, quiet: true) + + if Storage.shared[.playNotificationSoundInForeground] { + 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 threadId: String = self.viewModel.threadData.threadId + + Storage.shared.writeAsync { db in + TypingIndicators.didStopTyping(db, threadId: threadId, direction: .outgoing) + + _ = try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.messageDraft.set(to: "")) } } - // 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() @@ -434,45 +512,111 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc 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 { - 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 { - 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 @@ - currentMentionStartIndex = nil - snInputView.hideMentionsUI() - } 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) + 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)) + ) + + if needsToStartTypingIndicator { + Storage.shared.writeAsync { db in + TypingIndicators.start(db, threadId: threadId, direction: .outgoing) } } } - oldText = newText + + 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( + threadId: self.viewModel.threadData.threadId, + 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.threadData.threadVariant)) " + ) + + snInputView.text = newText + self.currentMentionStartIndex = nil + snInputView.hideMentionsUI() + + mentions = mentions.filter { mentionInfo -> Bool in + newText.contains(mentionInfo.profile.displayName(for: self.viewModel.threadData.threadVariant)) + } + } + + func updateMentions(for newText: String) { + guard !newText.isEmpty else { + if currentMentionStartIndex != nil { + snInputView.hideMentionsUI() + } + + 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)) + } + } } func resetMentions() { - oldText = "" currentMentionStartIndex = nil mentions = [] } @@ -480,23 +624,13 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc 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 } - 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 @@ -504,453 +638,802 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc }) } - // MARK: View Item Interaction - func handleViewItemLongPressed(_ viewItem: ConversationViewItem) { + // MARK: MessageCellDelegate + + func handleItemLongPressed(_ cellViewModel: MessageViewModel) { // 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 } + 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 }), + 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( + for: cellViewModel, + currentUserIsOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin( + self.viewModel.threadData.currentUserPublicKey, + for: self.viewModel.threadData.openGroupRoomToken, + on: self.viewModel.threadData.openGroupServer + ), + delegate: self + ) + 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 + self.contextMenuWindow = ContextMenuWindow() + self.contextMenuVC = ContextMenuVC( + snapshot: snapshot, + frame: cell.convert(cell.bubbleView.frame, to: keyWindow), + cellViewModel: cellViewModel, + 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() - self.unreadCountView.alpha = self.scrollButton.alpha + self?.scrollButton.alpha = (self?.getScrollButtonOpacity() ?? 0) + self?.unreadCountView.alpha = (self?.scrollButton.alpha ?? 0) } } - self.contextMenuVC = contextMenuVC - contextMenuWindow = window - window.rootViewController = contextMenuVC - window.makeKeyAndVisible() - window.backgroundColor = .clear + + self.contextMenuWindow?.backgroundColor = .clear + self.contextMenuWindow?.rootViewController = self.contextMenuVC + self.contextMenuWindow?.makeKeyAndVisible() } - func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) { - func confirmDownload() { - let modal = DownloadAttachmentModal(viewItem: viewItem) + func handleItemTapped(_ cellViewModel: MessageViewModel, gestureRecognizer: UITapGestureRecognizer) { + guard cellViewModel.variant != .standardOutgoing || cellViewModel.state != .failed else { + // Show the failed message sheet + showFailedMessageSheet(for: cellViewModel) + 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) modal.modalPresentationStyle = .overFullScreen modal.modalTransitionStyle = .crossDissolve + present(modal, animated: true, completion: nil) + return } - if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call { - let caller = (thread as! TSContactThread).name() - let callMissedTipsModal = CallMissedTipsModal(caller: caller) - present(callMissedTipsModal, animated: true, completion: nil) - } else 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, - Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true { - confirmDownload() - } else { - playOrPauseAudio(for: viewItem) - } + + switch cellViewModel.cellType { + case .audio: viewModel.playOrPauseAudio(for: cellViewModel) + 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, - Storage.shared.getContact(with: thread.contactSessionID())?.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() { - // 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 + guard + 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 } + + 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 .pendingDownload, .downloading, .uploading, .invalid: break + + // Failed uploads should be handled via the "resend" process instead + case .failedUpload: break + + case .failedDownload: + let threadId: String = self.viewModel.threadData.threadId + + // Retry downloading the failed attachment + Storage.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, + interactionId: cellViewModel.id, + selectedAttachmentId: mediaView.attachment.id, + options: [ .sliderEnabled, .showAllMediaButton ] + ) + + if let viewController: UIViewController = viewController { + /// 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(250)) { [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 + } + } } - } - guard let stream = attachment as? TSAttachmentStream else { return } - let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ]) - gallery.presentDetailView(fromViewController: self, mediaAttachment: stream) } + case .genericAttachment: - if viewItem.interaction is TSIncomingMessage, - let thread = self.thread as? TSContactThread, - Storage.shared.getContact(with: thread.contactSessionID())?.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) + guard + let attachment: Attachment = cellViewModel.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 + 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) - if UIDevice.current.isIPad { - shareVC.excludedActivityTypes = [] - shareVC.popoverPresentationController?.permittedArrowDirections = [] - shareVC.popoverPresentationController?.sourceView = self.view - shareVC.popoverPresentationController?.sourceRect = self.view.bounds - } - navigationController!.present(shareVC, animated: true, completion: nil) + + // Otherwise share the file + let shareVC = UIActivityViewController(activityItems: [ fileUrl ], applicationActivities: nil) + + if UIDevice.current.isIPad { + shareVC.excludedActivityTypes = [] + shareVC.popoverPresentationController?.permittedArrowDirections = [] + shareVC.popoverPresentationController?.sourceView = self.view + shareVC.popoverPresentationController?.sourceRect = self.view.bounds } + + 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? = Storage.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 - } } } - func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState) { + 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) + default: break + } + } + + func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) { switch state { - case .began: - messagesTableView.isScrollEnabled = false - case .ended, .cancelled: - messagesTableView.isScrollEnabled = true - } - } - - 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)) - presentAlert(alert) - } - - 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)) - presentAlert(alert) + self.navigationController?.pushViewController(conversationVC, animated: true) } func contextMenuDismissed() { recoverInputView() } - - func handleQuoteViewCancelButtonTapped() { - snInputView.quoteDraftInfo = nil + + // MARK: --action handling + + 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 + Storage.shared.writeAsync { db in + try Interaction + .filter(id: cellViewModel.id) + .deleteAll(db) + } + })) + sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ 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), + let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) + 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 = cellViewModel.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 + Storage.shared.writeAsync { db in + _ = try Interaction + .filter(id: cellViewModel.id) + .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 cellViewModel.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?)? = Storage.shared.read { db -> (Int64?, OpenGroup?) in + ( + try Interaction + .select(.openGroupServerMessageId) + .filter(id: cellViewModel.id) + .asRequest(of: Int64.self) + .fetchOne(db), + try OpenGroup.fetchOne(db, id: threadId) + ) + } + + guard + let openGroup: OpenGroup = result?.openGroup, + let openGroupServerMessageId: Int64 = result?.openGroupServerMessageId, ( + cellViewModel.variant != .standardIncoming || + OpenGroupManager.isUserModeratorOrAdmin( + userPublicKey, + for: openGroup.roomToken, + on: openGroup.server + ) + ) + else { return } + + // Delete the message from the open group + deleteRemotely( + from: self, + request: Storage.shared.read { db in + OpenGroupAPI.messageDelete( + db, + id: openGroupServerMessageId, + in: openGroup.roomToken, + on: openGroup.server + ) + .map { _ in () } + } + ) { [weak self] in + self?.showInputAccessoryView() + } + + case .contact, .closedGroup: + let serverHash: String? = Storage.shared.read { db -> String? in + try Interaction + .select(.serverHash) + .filter(id: cellViewModel.id) + .asRequest(of: String.self) + .fetchOne(db) + } + let unsendRequest: UnsendRequest = UnsendRequest( + timestamp: UInt64(cellViewModel.timestampMs), + author: (cellViewModel.variant == .standardOutgoing ? + userPublicKey : + cellViewModel.authorId + ) + ) + + // For incoming interactions or interactions with no serverHash just delete them locally + guard cellViewModel.variant == .standardOutgoing, let serverHash: String = serverHash else { + Storage.shared.writeAsync { db in + _ = try Interaction + .filter(id: cellViewModel.id) + .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: threadId, + 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 + Storage.shared.writeAsync { db in + _ = try Interaction + .filter(id: cellViewModel.id) + .deleteAll(db) + + MessageSender + .send( + db, + message: unsendRequest, + threadId: threadId, + interactionId: nil, + to: .contact(publicKey: userPublicKey) + ) + } + self?.showInputAccessoryView() + }) + + alertVC.addAction(UIAlertAction( + title: (cellViewModel.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: threadId, + serverHashes: [serverHash] + ) + .map { _ in () } + ) { [weak self] in + Storage.shared.writeAsync { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { + return + } + + 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(_ cellViewModel: MessageViewModel) { + guard cellViewModel.cellType == .mediaMessage else { return } + + let mediaAttachments: [(Attachment, String)] = (cellViewModel.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.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else { + return + } + + let threadId: String = self.viewModel.threadData.threadId + + Storage.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(cellViewModel.timestampMs)) + ), + interactionId: nil, + in: thread + ) + } + } + + func ban(_ cellViewModel: MessageViewModel) { + guard cellViewModel.threadVariant == .openGroup else { return } + + 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.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in + Storage.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()) + } + .retainUntilComplete() + + self?.becomeFirstResponder() + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in + self?.becomeFirstResponder() + })) + + present(alert, animated: true, completion: nil) + } + + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) { + guard cellViewModel.threadVariant == .openGroup else { return } + + 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.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { [weak self] _ in + Storage.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()) + } + .retainUntilComplete() + + self?.becomeFirstResponder() + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: { [weak self] _ in + self?.becomeFirstResponder() + })) + + 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) + let isConfigured = (Environment.shared?.audioSession.startAudioActivity(recordVoiceMessageActivity) == true) 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.") @@ -960,34 +1443,49 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc 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: "") } @@ -1001,36 +1499,111 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc func stopVoiceMessageRecording() { audioRecorder?.stop() - audioSession.endAudioActivity(recordVoiceMessageActivity) + Environment.shared?.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 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 } - */ } - - 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) + + func requestMicrophonePermissionIfNeeded(onNotGranted: @escaping () -> Void) { + switch AVAudioSession.sharedInstance().recordPermission { + case .granted: break + case .denied: + onNotGranted() + 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 + } + } + + func requestLibraryPermissionIfNeeded(onAuthorized: @escaping () -> Void) { + let authorizationStatus: PHAuthorizationStatus + if #available(iOS 14, *) { + authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + if authorizationStatus == .notDetermined { + // When the user chooses to select photos (which is the .limit status), + // the PHPhotoUI will present the picker view on the top of the front view. + // Since we have the ScreenLockUI showing when we request premissions, + // the picker view will be presented on the top of the ScreenLockUI. + // However, the ScreenLockUI will dismiss with the permission request alert view, so + // 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 + 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 + // appear to be a good way to do so though... + AppModeManager.shared.setCurrentAppMode(to: .light) + PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in + DispatchQueue.main.async { + AppModeManager.shared.setCurrentAppMode(to: appMode) + } + Environment.shared?.isRequestingPermission = false + if [ PHAuthorizationStatus.authorized, PHAuthorizationStatus.limited ].contains(status) { + onAuthorized() + } + } + } + } else { + authorizationStatus = PHPhotoLibrary.authorizationStatus() + if authorizationStatus == .notDetermined { + PHPhotoLibrary.requestAuthorization { status in + if status == .authorized { + onAuthorized() + } + } + } + } + + 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 } } // 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?() } } @@ -1047,172 +1620,227 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { // MARK: - Message Request Actions extension ConversationVC { - - fileprivate func approveMessageRequestIfNeeded(for thread: TSThread?, isNewThread: Bool, timestamp: UInt64) -> Promise { - guard let contactThread: TSContactThread = thread as? TSContactThread else { return Promise.value(()) } - + fileprivate func approveMessageRequestIfNeeded( + for threadId: String, + threadVariant: SessionThread.Variant, + isNewThread: Bool, + timestampMs: Int64 + ) { + 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 // shouldn't cause weird behaviours) - let sessionId: String = contactThread.contactSessionID() - let contact: Contact = (Storage.shared.getContact(with: sessionId) ?? Contact(sessionID: sessionId)) + guard + let approvalData: (contact: Contact, thread: SessionThread?) = Storage.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 + } - guard !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) } - + 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 - ) - messageRequestResponse.sentTimestamp = timestamp - - // 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 - 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() - } - - return promise - } - .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.write { transaction in - contact.isApproved = true - contact.didApproveMe = (contact.didApproveMe || !isNewThread) - Storage.shared.setContact(contact, using: transaction) - } + try approvalData.contact + .with( + isApproved: true, + didApproveMe: .update(approvalData.contact.didApproveMe || !isNewThread) + ) + .save(db) // 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) + try MessageSender + .syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() + }, + completion: { _, _ in + // Remove the 'MessageRequestsViewController' from the nav hierarchy if present 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() - if let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, - let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }), - messageRequestsIndex > 0 { + 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) + self?.navigationController?.viewControllers = newViewControllers } } } - } - - @objc func acceptMessageRequest() { - let promise: Promise = self.approveMessageRequestIfNeeded( - for: self.thread, - isNewThread: false, - timestamp: NSDate.millisecondTimestamp() ) - - // 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)) - self?.present(alert, animated: true, completion: nil) - } - - promise.retainUntilComplete() } - + + @objc func acceptMessageRequest() { + self.approveMessageRequestIfNeeded( + for: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, + isNewThread: false, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + } + @objc func deleteMessageRequest() { - guard let uniqueId: String = thread.uniqueId else { return } + guard self.viewModel.threadData.threadVariant == .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.threadData.threadId + 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 - Storage.write( - with: { [weak self] transaction in - Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction) - + Storage.shared.writeAsync( + updates: { 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 - + _ = 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 - contact.didApproveMe = true - - Storage.shared.setContact(contact, using: transaction) - } - } + didApproveMe: true + ) + .saved(db) - // Delete all thread content - self?.thread.removeAllThreadInteractions(with: transaction) + _ = try SessionThread + .filter(id: threadId) + .deleteAll(db) + + try MessageSender + .syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() }, - completion: { [weak self] in - // Force a config sync and pop to the previous screen - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - - // Remove the thread after the config message is sent. - // Otherwise the blocked user won't be included in the - // config message. - self?.thread.remove() - - DispatchQueue.main.async { + completion: { db, _ in + 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) } } + +// 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: 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 + 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) && + frameInBubble.minX < CGFloat.leastNonzeroMagnitude && + frameInBubble.minY < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMinXMinYCorner) + } + + if + cellMaskedCorners.contains(.layerMaxXMinYCorner) && + abs(frameInBubble.maxX - messageCell.bubbleView.bounds.width) < CGFloat.leastNonzeroMagnitude && + frameInBubble.minY < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMaxXMinYCorner) + } + + if + cellMaskedCorners.contains(.layerMinXMaxYCorner) && + frameInBubble.minX < CGFloat.leastNonzeroMagnitude && + abs(frameInBubble.maxY - messageCell.bubbleView.bounds.height) < CGFloat.leastNonzeroMagnitude + { + newCornerMask.insert(.layerMinXMaxYCorner) + } + + if + cellMaskedCorners.contains(.layerMaxXMaxYCorner) && + 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, + cornerRadius: cornerRadius, + cornerMask: cornerMask + ) + } + + func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? { + return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) + } +} diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 201ff2ec8..b06c8d0b2 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -1,85 +1,105 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import DifferenceKit import SessionUIKit import SessionMessagingKit -import UIKit +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 - var focusedMessageIndexPath: IndexPath? - var initialUnreadCount: UInt = 0 - var unreadViewItems: [ConversationViewItem] = [] +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? + private var hasLoadedInitialThreadData: Bool = false + private var hasLoadedInitialInteractionData: Bool = false + private var currentTargetOffset: CGPoint? + private var isAutoLoadingNextPage: Bool = false + private var isLoadingMore: Bool = false + var isReplacingThread: Bool = false + + /// 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 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? var audioRecorder: AVAudioRecorder? var audioTimer: Timer? + // Context menu var contextMenuWindow: ContextMenuWindow? var contextMenuVC: ContextMenuVC? + // Mentions - var oldText = "" var currentMentionStartIndex: String.Index? - var mentions: [Mention] = [] + var mentions: [ConversationViewModel.MentionInfo] = [] + // Scrolling & paging var isUserScrolling = false + var hasPerformedInitialScroll = false var didFinishInitialLayout = false - var isLoadingMore = false var scrollDistanceToBottomBeforeUpdate: CGFloat? 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 } - + /// 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 && + + // 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() { - return nil - } else { - return isShowingSearchUI ? searchController.resultsBar : snInputView - } + guard + viewModel.threadData.threadVariant != .closedGroup || + viewModel.threadData.currentUserIsClosedGroupMember == true + 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 = { - 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.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 @@ -87,80 +107,90 @@ 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( + threadId: self.viewModel.threadData.threadId + ) + 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 + + lazy var titleView: ConversationTitleView = { + let result: ConversationTitleView = ConversationTitleView() + let tapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(handleTitleViewTapped) + ) + result.addGestureRecognizer(tapGestureRecognizer) + + return result + }() + + lazy var tableView: InsetLockableTableView = { + let result: InsetLockableTableView = InsetLockableTableView() + 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.registerHeaderFooterView(view: UITableViewHeaderFooterView.self) + result.register(view: VisibleMessageCell.self) + result.register(view: InfoMessageCell.self) + result.register(view: TypingIndicatorCell.self) + result.register(view: CallMessageCell.self) + result.dataSource = self + result.delegate = self + + return result + }() + + lazy var snInputView: InputView = InputView( + threadVariant: self.viewModel.threadData.threadVariant, + 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 { - let publicKey = thread.contactSessionID() - let context = Contact.context(for: thread) - name = Storage.shared.getContact(with: publicKey)?.displayName(for: context) ?? publicKey - } else { - name = "Thread" - } - let message = "\(name) is blocked. Unblock them?" - let result = InfoBanner(message: message, backgroundColor: Colors.destructive) + let result: InfoBanner = InfoBanner( + message: self.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 @@ -170,21 +200,24 @@ 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 = ( + self.viewModel.threadData.threadIsMessageRequest == false || + self.viewModel.threadData.threadRequiresApproval == true + ) result.setGradient(Gradients.defaultBackground) - + return result }() - + private let messageRequestDescriptionLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false @@ -193,11 +226,11 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat result.textColor = Colors.sessionMessageRequestsInfoText result.textAlignment = .center result.numberOfLines = 2 - + return result }() - - private let messageRequestAcceptButton: UIButton = { + + private lazy var messageRequestAcceptButton: UIButton = { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true @@ -211,24 +244,18 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat 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) - + return result }() - - private let messageRequestDeleteButton: UIButton = { + + private lazy var messageRequestDeleteButton: UIButton = { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true @@ -242,24 +269,19 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat 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) - + 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 @@ -271,47 +293,56 @@ 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 + init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil) { + self.viewModel = ConversationViewModel(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId) + + Storage.shared.addObserver(viewModel.pagedDataObserver) + super.init(nibName: nil, bundle: nil) - Storage.read { transaction in - self.initialUnreadCount = self.thread.unreadMessageCount(transaction: transaction) - } - let clampedUnreadCount = min(self.initialUnreadCount, UInt(kConversationInitialMaxRangeSize), UInt(viewItems.endIndex)) - unreadViewItems = clampedUnreadCount != 0 ? [ConversationViewItem](viewItems[viewItems.endIndex - Int(clampedUnreadCount) ..< viewItems.endIndex]) : [] } - + 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() + + // 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, initialVariant: self.viewModel.initialThreadVariant) + titleView.initialSetup(with: self.viewModel.initialThreadVariant) + // Constraints - view.addSubview(messagesTableView) - messagesTableView.pin(to: view) - - // Blocked banner - addOrRemoveBlockedBanner() - + view.addSubview(tableView) + tableView.pin(to: view) + // 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) @@ -319,13 +350,11 @@ 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() 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) @@ -337,7 +366,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat 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) @@ -347,175 +376,608 @@ 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() - + // 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) - // Mentions - MentionsManager.populateUserPublicKeyCacheIfNeeded(for: thread.uniqueId!) - // Draft - var draft = "" - Storage.read { transaction in - draft = self.thread.currentDraft(with: transaction) - } - if !draft.isEmpty { - snInputView.text = draft + 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 + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + startObservingChanges() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // Flag that the initial layout has been completed (the flag blocks and unblocks a number + // of different behaviours) + didFinishInitialLayout = true + + if delayFirstResponder || isShowingSearchUI { + delayFirstResponder = false + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + (self?.isShowingSearchUI == false ? + self : + self?.searchController.uiSearchController.searchBar + )?.becomeFirstResponder() + } } - // 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()) + recoverInputView() + } + + 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 } + + stopObservingChanges() + viewModel.updateDraft(to: snInputView.text) + inputAccessoryView?.resignFirstResponder() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + mediaCache.removeAllObjects() + hasReloadedThreadDataAfterDisappearance = false + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges(didReturnFromBackground: true) + recoverInputView() + } + + @objc func applicationDidResignActive(_ notification: Notification) { + stopObservingChanges() + } + + // MARK: - Updating + + private func startObservingChanges(didReturnFromBackground: Bool = false) { + // Start observing for data changes + dataChangeObservable = Storage.shared.start( + viewModel.observableThreadData, + onError: { _ in }, + onChange: { [weak self] maybeThreadData in + 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 = Storage.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() + Storage.shared.removeObserver(self?.viewModel.pagedDataObserver) + + // Swap the observing to the updated thread + self?.viewModel.swapToThread(updatedThreadId: unblindedId) + + // Start observing changes again + Storage.shared.addObserver(self?.viewModel.pagedDataObserver) + self?.startObservingChanges() + return + } + + // The default scheduler emits changes on the main thread + self?.handleThreadUpdates(threadData) + + // 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) + } + + // 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() { + // Stop observing database changes + dataChangeObservable?.cancel() + self.viewModel.onInteractionChange = nil + } + + 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 { + hasLoadedInitialThreadData = true + hasReloadedThreadDataAfterDisappearance = true + UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) } + return + } + + // Update general conversation UI + + 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 + ) + } + + if + initialLoad || + viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || + viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || + viewModel.threadData.profile != updatedThreadData.profile + { + updateNavBarButtons(threadData: updatedThreadData, initialVariant: viewModel.initialThreadVariant) - // 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 - ), + 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 { + addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true)) + } + + if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { + updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) + } + + if initialLoad || viewModel.threadData.enabledMessageTypes != updatedThreadData.enabledMessageTypes { + snInputView.setEnabledMessageTypes( + updatedThreadData.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() + // Only set the draft content on the initial load + if initialLoad, let draft: String = updatedThreadData.threadMessageDraft, !draft.isEmpty { + snInputView.text = draft } - } - - 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() + + // 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 { + if !self.isFirstResponder { + self.becomeFirstResponder() + } + else { + self.reloadInputViews() } } } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - highlightFocusedMessageIfNeeded() - didFinishInitialLayout = true - markAllAsRead() - recoverInputView() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - let text = snInputView.text - Storage.write { transaction in - self.thread.setDraft(text, transaction: transaction) + 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 self.hasLoadedInitialInteractionData else { + self.hasLoadedInitialInteractionData = true + self.viewModel.updateInteractionData(updatedData) + + UIView.performWithoutAnimation { + self.tableView.reloadData() + self.performInitialScrollIfNeeded() + } + return + } + + // Store the 'sentMessageBeforeUpdate' state locally + let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate + 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() + + // 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 visibleIndexPath: IndexPath + let oldVisibleIndexPath: IndexPath + + init( + isInsertAtTop: Bool = false, + firstIndexIsVisible: Bool = false, + visibleIndexPath: IndexPath = IndexPath(row: 0, section: 0), + oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0) + ) { + self.isInsertAtTop = isInsertAtTop + self.firstIndexIsVisible = firstIndexIsVisible + self.visibleIndexPath = visibleIndexPath + self.oldVisibleIndexPath = oldVisibleIndexPath + } + } + + let changeset: StagedChangeset<[ConversationViewModel.SectionModel]> = StagedChangeset( + 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? = { + guard + 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 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 + }) + else { return nil } + + return ItemChangeInfo( + isInsertAtTop: ( + newSectionIndex > oldSectionIndex || + newFirstItemIndex > 0 + ), + firstIndexIsVisible: (firstVisibleIndexPath.row == 0), + visibleIndexPath: IndexPath(row: newVisibleIndex, 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 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) + 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 + self.tableView.afterNextLayoutSubviews( + when: { numSections, numRowsInSections, _ -> Bool in + numSections == updatedData.count && + numRowsInSections == numItemsInUpdatedData + }, + then: { [weak self] in + UIView.performWithoutAnimation { + let calculatedRowHeights: CGFloat = (0.. ConversationViewModel.pageSize } + ) { [weak self] updatedData in + self?.viewModel.updateInteractionData(updatedData) } } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - mediaCache.removeAllObjects() - self.resignFirstResponder() - } - - override func appDidBecomeActive(_ notification: Notification) { - recoverInputView() - } - - 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 + private func performInitialScrollIfNeeded() { + 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) + self.unreadCountView.alpha = self.scrollButton.alpha + } + else { + self.scrollToBottom(isAnimated: false) + } + + 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 + // + // 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: index) ?? .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: SessionThreadViewModel?, initialVariant: SessionThread.Variant) { + navigationItem.hidesBackButton = isShowingSearchUI + if isShowingSearchUI { navigationItem.leftBarButtonItem = nil navigationItem.rightBarButtonItems = [] } else { - var rightBarButtonItems: [UIBarButtonItem] = [] - 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 { - let size = Values.verySmallProfilePictureSize + 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 = [ + UIBarButtonItem( + customView: UIView( + frame: CGRect( + x: 0, + y: 0, + // Width of the standard back button minus an arbitrary amount to make the + // animation look good + width: (44 - 10), + height: 44 + ) + ) + ), + (initialVariant == .contact ? + UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))) : + nil + ) + ].compactMap { $0 } + return + } + + switch threadData.threadVariant { + case .contact: let profilePictureView = ProfilePictureView() - profilePictureView.accessibilityLabel = "Settings button" - profilePictureView.size = size - profilePictureView.update(for: thread) - profilePictureView.set(.width, to: size) - profilePictureView.set(.height, to: size) + profilePictureView.size = Values.verySmallProfilePictureSize + profilePictureView.update( + 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) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings)) profilePictureView.addGestureRecognizer(tapGestureRecognizer) - let settingsButton = UIBarButtonItem(customView: profilePictureView) - settingsButton.accessibilityLabel = "Settings button" - settingsButton.isAccessibilityElement = true - rightBarButtonItems.append(settingsButton) - 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) + + let settingsButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView) + settingsButtonItem.accessibilityLabel = "Settings button" + settingsButtonItem.isAccessibilityElement = true + + if SessionCall.isEnabled && !threadData.threadIsNoteToSelf { + let callButton = UIBarButtonItem( + image: UIImage(named: "Phone"), + style: .plain, + target: self, + action: #selector(startCall) + ) + + navigationItem.rightBarButtonItems = [settingsButtonItem, callButton] } - } - else { - // Note: Adding 2 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 - rightBarButtonItems.append(UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: Values.verySmallProfilePictureSize, height: 44)))) - rightBarButtonItems.append(UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 44, height: 44)))) - } + else { + navigationItem.rightBarButtonItem = settingsButtonItem + } + + default: + let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings)) + rightBarButtonItem.accessibilityLabel = "Settings button" + rightBarButtonItem.isAccessibilityElement = true + + navigationItem.rightBarButtonItems = [rightBarButtonItem] } - else { - let settingsButton = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings)) - settingsButton.accessibilityLabel = "Settings button" - settingsButton.isAccessibilityElement = true - rightBarButtonItems.append(settingsButton) - } - navigationItem.rightBarButtonItems = rightBarButtonItems - } - } - - private func highlightFocusedMessageIfNeeded() { - if let indexPath = focusedMessageIndexPath, let cell = messagesTableView.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 // and https://stackoverflow.com/a/25260930 to better understand what we are @@ -525,42 +987,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 +1030,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } return } - + UIView.animate( withDuration: duration, delay: 0, @@ -577,7 +1039,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 +1048,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,143 +1059,37 @@ 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() }, 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() + + // MARK: - General + + func addOrRemoveBlockedBanner(threadIsBlocked: Bool) { + guard threadIsBlocked else { + UIView.animate( + withDuration: 0.25, + animations: { [weak self] in + self?.blockedBanner.alpha = 0 + }, + completion: { [weak self] _ in + self?.blockedBanner.alpha = 1 + self?.blockedBanner.removeFromSuperview() } - - // 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? = Storage.shared.getContact(with: 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 ) + return } - } - - 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 handleGroupUpdatedNotification() { - 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 } - 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 !isLast else { continue } - if !cell.messageStatusImageView.isHidden { - indexPathsToReload.append(indexPath) - } - } - UIView.performWithoutAnimation { - self.messagesTableView.reloadRows(at: indexPathsToReload, with: .none) - } - } - } - - // 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() - } + + self.view.addSubview(self.blockedBanner) + self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view) } func recoverInputView() { @@ -743,17 +1099,71 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat self.snInputView.text = self.snInputView.text } } + + // MARK: - UITableViewDataSource - 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() + 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 section: ConversationViewModel.SectionModel = viewModel.interactionData[indexPath.section] + + switch section.model { + case .messages: + let cellViewModel: MessageViewModel = 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 + } + + 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 + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } @@ -761,85 +1171,139 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat 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.hasPerformedInitialScroll && !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 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 + !self.isUserScrolling, + let messagesSectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + !self.viewModel.interactionData[messagesSectionIndex] + .elements + .isEmpty + else { return } + + // 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 + } + + let targetIndexPath: IndexPath = IndexPath( + row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1), + section: messagesSectionIndex + ) + self.tableView.scrollToRow( + at: targetIndexPath, + at: .bottom, + animated: isAnimated + ) + + self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: .bottom) } - + 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) + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + guard + let focusedInteractionId: Int64 = self.focusedInteractionId, + self.shouldHighlightNextScrollToInteraction + else { + self.focusedInteractionId = nil + self.shouldHighlightNextScrollToInteraction = false + return } - let unreadCount = unreadViewItems.count - unreadCountLabel.text = unreadCount < 10000 ? "\(unreadCount)" : "9999+" - let fontSize = (unreadCount < 10000) ? Values.verySmallFontSize : 8 + + DispatchQueue.main.async { [weak self] in + self?.highlightCellIfNeeded(interactionId: focusedInteractionId) + } + } + + 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) 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 = 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 } + + // MARK: - Search - func groupWasUpdated(_ groupModel: TSGroupModel) { - // Not currently in use - } - - // MARK: Search 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 + } + + dismiss(animated: true) { + self.navigationController?.popToViewController(self, animated: true, completion: nil) } } - - 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) - } - } - + func showSearchUI() { isShowingSearchUI = true + // Search bar let searchBar = searchController.uiSearchController.searchBar searchBar.setUpSessionStyle() @@ -858,7 +1322,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat if UIDevice.current.isIPad { let ipadCancelButton = UIButton() ipadCancelButton.setTitle("Cancel", for: .normal) - ipadCancelButton.addTarget(self, action: #selector(hideSearchUI(_ :)), for: .touchUpInside) + ipadCancelButton.addTarget(self, action: #selector(hideSearchUI), for: .touchUpInside) ipadCancelButton.setTitleColor(Colors.text, for: .normal) searchBarContainer.addSubview(ipadCancelButton) ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer) @@ -868,9 +1332,10 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat } else { searchBar.autoPinEdgesToSuperviewMargins() } - + // Nav bar buttons - updateNavBarButtons() + updateNavBarButtons(threadData: self.viewModel.threadData, initialVariant: viewModel.initialThreadVariant) + // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. // @@ -901,38 +1366,151 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat let navBar = navigationController!.navigationBar as! OWSNavigationBar navBar.stubbedNextResponder = self } - - @objc func hideSearchUI(_ sender: Any? = nil) { + + @objc func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView - updateNavBarButtons() - let navBar = navigationController!.navigationBar as! OWSNavigationBar - navBar.stubbedNextResponder = nil + updateNavBarButtons(threadData: self.viewModel.threadData, initialVariant: viewModel.initialThreadVariant) + + let navBar: OWSNavigationBar? = 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) + + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) { + viewModel.lastSearchedText = searchText + tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) + } + + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId interactionId: Int64) { + scrollToInteractionIfNeeded(with: interactionId, highlight: true) + } + + func scrollToInteractionIfNeeded( + with interactionId: Int64, + position: UITableView.ScrollPosition = .middle, + isAnimated: Bool = true, + highlight: Bool = false + ) { + // 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 { + // 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.isLoadingMore = true + 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 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: (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 + if highlight { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in + self?.highlightCellIfNeeded(interactionId: interactionId) + } + } + + self.shouldHighlightNextScrollToInteraction = false + self.focusedInteractionId = nil + 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) + self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: position) } - func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectMessageId interactionID: String) { - scrollToInteraction(with: interactionID, highlighted: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - self.highlightFocusedMessageIfNeeded() + 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() } } - 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 + 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] 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 + + self?.tableView.scrollToRow( + at: targetIndexPath, + at: position, + animated: false + ) + + return false + }, + then: {} + ) } } } 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/ConversationViewItem.h b/Session/Conversations/ConversationViewItem.h deleted file mode 100644 index 46ba1edaa..000000000 --- a/Session/Conversations/ConversationViewItem.h +++ /dev/null @@ -1,165 +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 SNVoiceMessageView; -@class OWSLinkPreview; -@class OWSQuotedReplyModel; -@class OWSUnreadIndicator; -@class TSAttachment; -@class TSAttachmentPointer; -@class TSAttachmentStream; -@class TSInteraction; -@class TSThread; -@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)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 d39057a01..000000000 --- a/Session/Conversations/ConversationViewItem.m +++ /dev/null @@ -1,1130 +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 -#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"; - case OWSMessageCellType_DeletedMessage: - return @"OWSMessageCellType_DeletedMessage"; - } -} - -#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]; -} - -- (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 = [LKStorage.shared 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 = [LKStorage.shared 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 0c816e8a1..000000000 --- a/Session/Conversations/ConversationViewModel.m +++ /dev/null @@ -1,1466 +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 - -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; -} - -- (OWSProfileManager *)profileManager -{ - return [OWSProfileManager sharedManager]; -} - -#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:kNSNotificationName_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]; - [self addNotificationListeners]; -} - -- (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) { - SNContactContext context = [SNContact contextForThread:self.thread]; - senderName = [[NSAttributedString alloc] initWithString:[[LKStorage.shared getContactWithSessionID:incomingSenderId] displayNameFor:context] ?: incomingSenderId]; - } - - // 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 new file mode 100644 index 000000000..c151ea467 --- /dev/null +++ b/Session/Conversations/ConversationViewModel.swift @@ -0,0 +1,714 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SessionMessagingKit +import SessionUtilitiesKit + +public class ConversationViewModel: OWSAudioPlayerDelegate { + public typealias SectionModel = ArraySection + + // MARK: - Action + + public enum Action { + case none + case compose + case audioCall + case videoCall + } + + // MARK: - Section + + public enum Section: Differentiable, Equatable, Comparable, Hashable { + case loadOlder + case messages + case loadNewer + } + + // MARK: - Variables + + public static let pageSize: Int = 50 + + private var 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, 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 Storage.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 = targetInteractionId + 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) + 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 + // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query + // from a `0` offset) + guard let initialFocusedId: Int64 = targetInteractionId 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) lazy var threadData: SessionThreadViewModel = SessionThreadViewModel( + threadId: self.threadId, + threadVariant: self.initialThreadVariant, + currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ? + nil : + Storage.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 + /// + /// **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>> = 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, userPublicKey: String) -> PagedDatabaseObserver { + return PagedDatabaseObserver( + pagedTable: Interaction.self, + pageSize: ConversationViewModel.pageSize, + idColumn: .id, + observedChanges: [ + PagedData.ObservedChanges( + table: Interaction.self, + 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])") + }() + ), + 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), + groupSQL: MessageViewModel.groupSQL, + orderSQL: MessageViewModel.orderSQL, + dataQuery: MessageViewModel.baseQuery( + userPublicKey: userPublicKey, + orderSQL: MessageViewModel.orderSQL, + groupSQL: MessageViewModel.groupSQL + ), + associatedRecords: [ + AssociatedRecord( + trackedAgainst: Attachment.self, + observedChanges: [ + PagedData.ObservedChanges( + table: Attachment.self, + columns: [.state] + ) + ], + dataQuery: MessageViewModel.AttachmentInteractionInfo.baseQuery, + joinToPagedType: MessageViewModel.AttachmentInteractionInfo.joinToViewModelQuerySQL, + 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 + guard let updatedInteractionData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + // 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) + } + ) + } + + private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { + let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true }) + let sortedData: [MessageViewModel] = data + .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 + // there are newer pages to load + return [ + (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + [SectionModel(section: .loadOlder)] : + [] + ), + [ + SectionModel( + section: .messages, + elements: sortedData + .enumerated() + .map { index, cellViewModel -> MessageViewModel in + cellViewModel.withClusteringChanges( + 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.pageOffset == 0 + ), + currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey + ) + } + .appending(typingIndicator) + ) + ], + (!data.isEmpty && pageInfo.pageOffset > 0 ? + [SectionModel(section: .loadNewer)] : + [] + ) + ].flatMap { $0 } + } + + public func updateInteractionData(_ updatedData: [SectionModel]) { + self.interactionData = updatedData + } + + // MARK: - Mentions + + public struct MentionInfo: FetchableRecord, Decodable { + fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue + fileprivate static let openGroupServerKey = CodingKeys.openGroupServer.stringValue + fileprivate static let openGroupRoomTokenKey = CodingKeys.openGroupRoomToken.stringValue + + let profile: Profile + let threadVariant: SessionThread.Variant + let openGroupServer: String? + let openGroupRoomToken: String? + } + + public func mentions(for query: String = "") -> [MentionInfo] { + let threadData: SessionThreadViewModel = self.threadData + + let results: [MentionInfo] = Storage.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, + openGroupServer: nil, + openGroupRoomToken: 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.openGroupServer)").forKey(MentionInfo.openGroupServerKey), + SQL("\(threadData.openGroupRoomToken)").forKey(MentionInfo.openGroupRoomTokenKey) + ) + .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) { + 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: threadId) + .updateAll(db, SessionThread.Columns.messageDraft.set(to: draft)) + } + } + + public func markAllAsRead() { + // 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) + + Storage.shared.writeAsync { db in + try Interaction.markAsRead( + db, + interactionId: lastInteractionId, + threadId: threadId, + includingOlder: true, + trySendReadReceipt: trySendReadReceipt + ) + } + } + + 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, + 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 + 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 { + 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: 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] { + 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: MessageViewModel) { + 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 + // 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, + delegate: self + ) + audioPlayer.play() + audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0) + player = audioPlayer + } + } + + 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) + 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 { + // 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 + + 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 { + // 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 + 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: MessageViewModel = messageSection.elements[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/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..474c4d6db 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -1,51 +1,64 @@ +// 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 { - case all - case textOnly - case none - } +final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { + // 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 linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)? + + var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } + var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)? 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 { + var selectedRange: NSRange { + get { inputTextView.selectedRange } + set { inputTextView.selectedRange = newValue } + } + + var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder } + + var enabledMessageTypes: MessageInputTypes = .all { didSet { 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 +68,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 +99,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 @@ -91,71 +107,78 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, 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) @@ -163,12 +186,14 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, 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() } @@ -180,7 +205,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, autoGenerateLinkPreviewIfPossible() delegate?.inputTextViewDidChangeContent(inputTextView) } - + func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) { delegate?.didPasteImageFromPasteboard(image) } @@ -188,15 +213,31 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, // 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, + currentUserPublicKey: nil, + currentUserBlindedPublicKey: nil, + 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) @@ -207,64 +248,78 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, 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! - let userDefaults = UserDefaults.standard - if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled - && !userDefaults[.hasSeenLinkPreviewSuggestion] { + let areLinkPreviewsEnabled: Bool = Storage.shared[.areLinkPreviewsEnabled] + + if + !LinkPreview.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() } 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 } + // 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: LinkPreview.LoadingState(), 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() - } - - func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) { - guard enabledMessageTypes != messageTypes else { return } + // Build the link preview + 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: LinkPreview.DraftState(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: MessageInputTypes, 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 ? @@ -278,35 +333,40 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, 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() } } @@ -329,23 +389,18 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, voiceMessageRecordingView.handleLongPressEnded(at: location) } - func handleQuoteViewCancelButtonTapped() { - delegate?.handleQuoteViewCancelButtonTapped() - } - override func resignFirstResponder() -> Bool { inputTextView.resignFirstResponder() } + + func inputTextViewBecomeFirstResponder() { + inputTextView.becomeFirstResponder() + } func handleLongPress() { // 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) @@ -373,50 +428,53 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, } 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 + // 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 } } -// 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/Input View/InputViewButton.swift b/Session/Conversations/Input View/InputViewButton.swift index 43166a5f0..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) @@ -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/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 327f50ec7..0eceafcea 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -1,36 +1,50 @@ +// 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() } } - var openGroupServer: String? - 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 +52,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, + isUserModeratorOrAdmin: OpenGroupManager.isUserModeratorOrAdmin( + candidates[indexPath.row].profile.id, + for: candidates[indexPath.row].openGroupRoomToken, + 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 +107,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 +172,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) @@ -157,24 +187,28 @@ private extension MentionSelectionView { separator.pin(.bottom, to: .bottom, of: self) } - // MARK: Updating - private func update() { - displayNameLabel.text = mentionCandidate.displayName - profilePictureView.publicKey = mentionCandidate.publicKey - profilePictureView.update() - if let server = openGroupServer, let room = openGroupRoom { - let isUserModerator = OpenGroupAPIV2.isUserModerator(mentionCandidate.publicKey, for: room, on: server) - moderatorIconImageView.isHidden = !isUserModerator - } else { - moderatorIconImageView.isHidden = true - } + // MARK: - Updating + + fileprivate func update( + with profile: Profile, + threadVariant: SessionThread.Variant, + isUserModeratorOrAdmin: Bool, + isLast: Bool + ) { + displayNameLabel.text = profile.displayName(for: threadVariant) + profilePictureView.update( + publicKey: profile.id, + profile: profile, + threadVariant: threadVariant + ) + moderatorIconImageView.isHidden = !isUserModeratorOrAdmin + separator.isHidden = isLast } } } // MARK: - Delegate -protocol MentionSelectionViewDelegate : class { - - func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) +protocol MentionSelectionViewDelegate: AnyObject { + func handleMentionSelected(_ mention: ConversationViewModel.MentionInfo, 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/Conversations/LongTextViewController.swift b/Session/Conversations/LongTextViewController.swift deleted file mode 100644 index 001b591a0..000000000 --- a/Session/Conversations/LongTextViewController.swift +++ /dev/null @@ -1,177 +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() { - let shareVC = UIActivityViewController(activityItems: [ fullText ], applicationActivities: nil) - if UIDevice.current.isIPad { - shareVC.excludedActivityTypes = [] - shareVC.popoverPresentationController?.permittedArrowDirections = [] - shareVC.popoverPresentationController?.sourceView = self.view - shareVC.popoverPresentationController?.sourceRect = self.view.bounds - } - self.present(shareVC, animated: true, completion: nil) - } -} diff --git a/Session/Conversations/Message Cells/CallMessageCell.swift b/Session/Conversations/Message Cells/CallMessageCell.swift index f199b49b6..ff98141ff 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 && + !Storage.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 && !Storage.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..f27302f8f 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")? + .resizedImage(to: CGSize(width: CallMessageView.iconSize, height: CallMessageView.iconSize))? + .withRenderingMode(.alwaysTemplate) + ) + 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/Content Views/DeletedMessageView.swift b/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift index 0e353972e..22393d1a9 100644 --- a/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift +++ b/Session/Conversations/Message Cells/Content Views/DeletedMessageView.swift @@ -1,43 +1,51 @@ +// 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")? + .resizedImage(to: CGSize( + width: DeletedMessageView.iconSize, + height: DeletedMessageView.iconSize + ))? + .withRenderingMode(.alwaysTemplate) + let imageView = UIImageView(image: icon) + imageView.tintColor = textColor 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 +53,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..d24974df6 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,30 +23,34 @@ 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) + // 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/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index f41b626d4..054d19271 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -1,220 +1,138 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -extension CGPoint { +import UIKit +import SessionMessagingKit + +protocol LinkPreviewState { + var isLoaded: Bool { get } + var urlString: String? { get } + var title: String? { get } + var imageState: LinkPreview.ImageState { get } + var image: UIImage? { get } +} + +public extension LinkPreview { + enum ImageState: Int { + case none + case loading + case loaded + case invalid + } - public func offsetBy(dx: CGFloat) -> CGPoint { - return CGPoint(x: x + dx, y: y) + // 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 offsetBy(dy: CGFloat) -> CGPoint { - return CGPoint(x: x, y: y + dy) - } -} - -// 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 + 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 = linkPreviewDraft.title, - value.count > 0 else { + + var imageState: LinkPreview.ImageState { + if linkPreviewDraft.jpegImageData != nil { return .loaded } + + return .none + } + + 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 value - } + + // MARK: - Type Specific + + private let linkPreviewDraft: LinkPreviewDraft + + // MARK: - Initialization - public func imageState() -> LinkPreviewImageState { - if linkPreviewDraft.jpegImageData != nil { - return .loaded - } else { - return .none + init(linkPreviewDraft: LinkPreviewDraft) { + self.linkPreviewDraft = linkPreviewDraft } } + + // MARK: SentState + + struct SentState: LinkPreviewState { + var isLoaded: Bool { true } + var urlString: String? { linkPreview.url } - public func image() -> UIImage? { - guard let jpegImageData = linkPreviewDraft.jpegImageData else { - return nil + var title: String? { + guard let value = linkPreview.title, value.count > 0 else { return nil } + + return value } - guard let image = UIImage(data: jpegImageData) else { - owsFailDebug("Could not load image: \(jpegImageData.count)") - return nil + + 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 .pendingDownload, .downloading, .uploading: return .loading + case .failedDownload, .failedUpload, .invalid: return .invalid + } } - return image - } -} -// MARK: - - -@objc -public class LinkPreviewSent: NSObject, LinkPreviewState { - private let linkPreview: OWSLinkPreview - private let imageAttachment: TSAttachment? - - @objc - public var imageSize: CGSize { - guard let attachmentStream = imageAttachment as? TSAttachmentStream else { - return CGSize.zero - } - return attachmentStream.imageSize() - } - - @objc - public required init(linkPreview: OWSLinkPreview, - imageAttachment: TSAttachment?) { - self.linkPreview = linkPreview - self.imageAttachment = imageAttachment - } - - public func isLoaded() -> Bool { - return true - } - - public func urlString() -> String? { - guard let urlString = linkPreview.urlString else { - owsFailDebug("Missing url") - return nil - } - return urlString - } - - public func displayDomain() -> String? { - guard let displayDomain = linkPreview.displayDomain() else { - Logger.error("Missing display domain") - return nil - } - return displayDomain - } - - public func title() -> String? { - guard let value = linkPreview.title, - value.count > 0 else { + 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 } - return value - } + + // MARK: - Type Specific + + private let linkPreview: LinkPreview + private let imageAttachment: Attachment? - public func imageState() -> LinkPreviewImageState { - guard linkPreview.imageAttachmentId != nil else { - return .none + 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)) } - guard let imageAttachment = imageAttachment else { - owsFailDebug("Missing imageAttachment.") - return .none - } - guard let attachmentStream = imageAttachment as? TSAttachmentStream else { - return .loading - } - guard attachmentStream.isImage, - attachmentStream.isValidImage else { - return .invalid - } - return .loaded - } + + // MARK: - Initialization - public func image() -> UIImage? { - guard let attachmentStream = imageAttachment as? TSAttachmentStream else { - return nil + init(linkPreview: LinkPreview, imageAttachment: Attachment?) { + self.linkPreview = linkPreview + self.imageAttachment = imageAttachment } - 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)") - return nil - } - return image } } - -// 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 086adece9..16eb6e9b5 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewView.swift @@ -1,97 +1,106 @@ -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 - result.setImage(UIImage(named: "X")?.withTint(tint), for: UIControl.State.normal) + // FIXME: This will have issues with theme transitions + let result: UIButton = UIButton(type: .custom) + 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) 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 +108,106 @@ 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, + cellViewModel: MessageViewModel? = 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 LinkPreview.DraftState || state is LinkPreview.SentState) { image = UIImage(named: "Link")?.withTint(isLightMode ? .black : .white) } + // Image view - let imageViewContainerSize: CGFloat = (linkPreviewState is LinkPreviewSent) ? 100 : 80 + let imageViewContainerSize: CGFloat = (state is LinkPreview.SentState ? 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 LinkPreview.SentState ? 0 : 8) + + if state is LinkPreview.LoadingState { 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 LinkPreview.SentState: + // 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, delegate: delegate) + + if let cellViewModel: MessageViewModel = cellViewModel { + let bodyTextView = VisibleMessageCell.getBodyTextView( + for: cellViewModel, + 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 LinkPreview.DraftState { 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/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..05ad35c01 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(cellViewModel: MessageViewModel, textColor: UIColor) { super.init(frame: CGRect.zero) - setUpViewHierarchy() + + setUpViewHierarchy(cellViewModel: cellViewModel, 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( + cellViewModel: MessageViewModel, + 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 + cellViewModel.variant == .standardIncoming, + let attachment: Attachment = cellViewModel.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)? + .resizedImage( + to: CGSize( + width: MediaPlaceholderView.iconSize, + height: MediaPlaceholderView.iconSize + ) + )? + .withRenderingMode(.alwaysTemplate) + ) + 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..255d65690 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,45 @@ public class MediaView: UIView { private func createContents() { AssertIsOnMainThread() - guard let attachmentStream = attachment as? TSAttachmentStream else { + guard attachment.state != .pendingDownload && attachment.state != .downloading else { addDownloadProgressIfNecessary() return } - guard !isFailedDownload else { + guard attachment.state != .failedDownload 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 { + guard attachment.isValid else { + configure(forError: .invalid) + return + } + + 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 != .failedDownload 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,65 +125,71 @@ 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 != .failedUpload else { + configure(forError: .failed) + 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) 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 animatedImageView.layer.magnificationFilter = .trilinear animatedImageView.backgroundColor = Colors.unimportant + animatedImageView.isHidden = !attachment.isValid addSubview(animatedImageView) animatedImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(animatedImageView) loadBlock = { [weak self] in AssertIsOnMainThread() - - 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) + self?.tryToLoadMedia( + loadMediaBlock: { applyMediaBlock in + 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 + } + + applyMediaBlock(YYImage(contentsOfFile: filePath)) + }, + applyMediaBlock: { media in + AssertIsOnMainThread() + + 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) + animatedImageView.image = image + }, + cacheKey: attachment.id + ) } unloadBlock = { AssertIsOnMainThread() @@ -225,23 +198,21 @@ 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 stillImageView.layer.magnificationFilter = .trilinear stillImageView.backgroundColor = Colors.unimportant + stillImageView.isHidden = !attachment.isValid addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() _ = addUploadProgressIfNecessary(stillImageView) + loadBlock = { [weak self] in AssertIsOnMainThread() @@ -249,29 +220,35 @@ 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 { + self?.configure(forError: .invalid) + return + } + + attachment.thumbnail( + size: .large, + success: { image, _ in applyMediaBlock(image) }, + failure: { + Logger.error("Could not load thumbnail") + self?.configure(forError: .invalid) + } + ) + }, + applyMediaBlock: { media in AssertIsOnMainThread() - + + guard let image: UIImage = media as? UIImage else { + owsFailDebug("Media has unexpected type: \(type(of: media))") + self?.configure(forError: .invalid) + 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,20 +257,17 @@ 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 stillImageView.layer.magnificationFilter = .trilinear stillImageView.backgroundColor = Colors.unimportant + stillImageView.isHidden = !attachment.isValid addSubview(stillImageView) stillImageView.autoPinEdgesToSuperviewEdges() @@ -314,29 +288,35 @@ 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 { + self?.configure(forError: .invalid) + return + } + + attachment.thumbnail( + size: .medium, + success: { image, _ in applyMediaBlock(image) }, + failure: { + Logger.error("Could not load thumbnail") + self?.configure(forError: .invalid) + } + ) + }, + applyMediaBlock: { media in AssertIsOnMainThread() + guard let image: UIImage = media as? UIImage else { + owsFailDebug("Media has unexpected type: \(type(of: media))") + self?.configure(forError: .invalid) + 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,100 +325,115 @@ 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 + // 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) } - icon = asset - case .invalid: - guard let asset = UIImage(named: "media_invalid") else { - owsFailDebug("Missing image") - return - } - icon = asset - case .missing: return } + + 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 + } + + 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() } - 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 { + loadCompletion(media) + } + 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) + + loadMediaBlock { media in + guard Thread.isMainThread else { + DispatchQueue.main.async { + loadCompletion(media) + } + return } - return - } - - DispatchQueue.main.async { - mediaCache.setObject(media, forKey: cacheKey as NSString) - + loadCompletion(media) } } @@ -459,32 +454,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/OpenGroupInvitationView.swift b/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift index 7a1065044..432b34aff 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[.. ())? - 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, + currentUserPublicKey: String?, + currentUserBlindedPublicKey: String?, + 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, + currentUserPublicKey: currentUserPublicKey, + currentUserBlindedPublicKey: currentUserBlindedPublicKey, + direction: direction, + attachment: attachment, + hInset: hInset, + maxWidth: maxWidth + ) } override init(frame: CGRect) { @@ -105,14 +62,24 @@ 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, + currentUserPublicKey: String?, + currentUserBlindedPublicKey: String?, + 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 @@ -120,18 +87,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 @@ -139,49 +111,129 @@ 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)? + .resizedImage(to: CGSize(width: iconSize, height: iconSize))? + .withRenderingMode(.alwaysTemplate) + ) + + 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") + ) + } + + // 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) + } + // 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, + currentUserPublicKey: currentUserPublicKey, + currentUserBlindedPublicKey: currentUserBlindedPublicKey, + 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 isCurrentUser: Bool = [ + currentUserPublicKey, + currentUserBlindedPublicKey, + ] + .compactMap { $0 } + .asSet() + .contains(authorId) 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 = (isCurrentUser ? + "MEDIA_GALLERY_SENDER_NAME_YOU".localized() : + Profile.displayName( + id: authorId, + threadVariant: threadVariant + ) + ) authorLabel.textColor = textColor authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) @@ -195,51 +247,56 @@ 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 - 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) + // 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 6520435aa..4e5b2ab94 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -1,72 +1,87 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class InfoMessageCell : MessageCell { +import UIKit +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: OWSDisappearingMessagesConfiguration? - Storage.read { transaction in - configuration = message.thread(with: transaction).disappearingMessagesConfiguration(with: transaction) + override func update(with cellViewModel: MessageViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + guard cellViewModel.variant.isInfoMessage else { return } + + self.viewModel = cellViewModel + + let icon: UIImage? = { + switch cellViewModel.variant { + case .infoDisappearingMessagesUpdate: + return (cellViewModel.threadHasDisappearingMessagesEnabled ? + UIImage(named: "ic_timer") : + UIImage(named: "ic_timer_disabled") + ) + + case .infoMediaSavedNotification: return UIImage(named: "ic_download") + + default: return nil } - 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 - } + }() + 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 iconImageViewHeightConstraint.constant = (icon != nil) ? InfoMessageCell.iconSize : 0 - Storage.read { transaction in - self.label.text = message.previewText(with: transaction) - } + + self.label.text = cellViewModel.body + } + + 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 9bc79bcfe..ef4155580 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,77 +9,79 @@ public enum SwipeState { case cancelled } -class MessageCell : UITableViewCell { +public class MessageCell: UITableViewCell { weak var delegate: MessageCellDelegate? - var thread: TSThread? { - didSet { - if viewItem != nil { update() } - } - } - var viewItem: ConversationViewItem? { - didSet { - if thread != nil { update() } - } - } + var viewModel: MessageViewModel? + + // 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 cellViewModel: MessageViewModel, 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: - if let message = viewItem.interaction as? TSInfoMessage, message.messageType == .call { + /// 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: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) { + preconditionFailure("Must be overridden by subclasses.") + } + + // MARK: - Convenience + + static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type { + guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self } + + switch viewModel.variant { + case .standardOutgoing, .standardIncoming, .standardIncomingDeleted: + return VisibleMessageCell.self + + case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, + .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, + .infoMessageRequestAccepted: + return InfoMessageCell.self + + case .infoCall: return CallMessageCell.self - } - return InfoMessageCell.self - case is TypingIndicatorInteraction: return TypingIndicatorCell.self - default: preconditionFailure() } } } -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: 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: MessageViewModel) + func showUserDetails(for profile: Profile) + func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) } diff --git a/Session/Conversations/Message Cells/TypingIndicatorCell.swift b/Session/Conversations/Message Cells/TypingIndicatorCell.swift index 87be75649..0b40253c2 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 cellViewModel: MessageViewModel, mediaCache: NSCache, playbackInfo: ConversationViewModel.PlaybackInfo?, lastSearchText: String?) { + guard cellViewModel.cellType == .typingIndicator else { return } + + self.viewModel = cellViewModel + // Bubble view updateBubbleViewCorners() + // Typing indicator view typingIndicatorView.startAnimation() } + + override func dynamicUpdate(with cellViewModel: MessageViewModel, 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 viewModel?.isOnlyMessageInCluster == false else { return .allCorners } + + switch viewModel?.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/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 640b427da..ccb099d8e 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1,9 +1,19 @@ +// 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) @@ -25,62 +35,52 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.delegate = self return result }() - - 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 lazy var viewsToMoveForReply: [UIView] = [ + bubbleView, + bubbleBackgroundView, + profilePictureView, + replyButton, + timerView, + messageStatusImageView + ] - 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 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 = { 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 @@ -88,7 +88,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 @@ -101,19 +101,21 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { result.alpha = 0 return result }() - + private lazy var replyIconImageView: UIImageView = { let result = UIImageView() 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 }() + + 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 @@ -125,63 +127,68 @@ final class VisibleMessageCell : MessageCell, LinkPreviewViewDelegate { static let smallCornerRadius: CGFloat = 4 static let largeCornerRadius: CGFloat = 18 static let contactThreadHSpacing = Values.mediumSpacing - + static var gutterSize: CGFloat = { var result = groupThreadHSpacing + profilePictureSize + groupThreadHSpacing + if UIDevice.current.isIPad { result += CGFloat(UIScreen.main.bounds.width / 2 - 88) } + return result }() - 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 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) 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 @@ -189,278 +196,447 @@ 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 cellViewModel: MessageViewModel, + mediaCache: NSCache, + playbackInfo: ConversationViewModel.PlaybackInfo?, + lastSearchText: String? + ) { + self.viewModel = cellViewModel + + let isGroupThread: Bool = (cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .closedGroup) + let shouldInsetHeader: Bool = ( + cellViewModel.previousVariant?.isInfoMessage != true && + ( + cellViewModel.positionInCluster == .top || + cellViewModel.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 = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil) + profilePictureView.update( + publicKey: cellViewModel.authorId, + profile: cellViewModel.profile, + threadVariant: cellViewModel.threadVariant + ) + moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile) + // 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 = ( + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted + ) + bubbleViewLeftConstraint1.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : VisibleMessageCell.contactThreadHSpacing) + bubbleViewLeftConstraint2.isActive = (cellViewModel.variant == .standardOutgoing) + bubbleViewTopConstraint.constant = (cellViewModel.senderName == nil ? 0 : VisibleMessageCell.authorLabelBottomSpacing) + bubbleViewRightConstraint1.isActive = (cellViewModel.variant == .standardOutgoing) + bubbleViewRightConstraint2.isActive = ( + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted + ) + bubbleView.backgroundColor = (( + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted + ) ? Colors.receivedMessageBackground : Colors.sentMessageBackground) + bubbleBackgroundView.backgroundColor = bubbleView.backgroundColor updateBubbleViewCorners() + // Content view - populateContentView(for: viewItem, message: message) + populateContentView( + for: cellViewModel, + 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: cellViewModel, 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 = (cellViewModel.senderName == nil) + authorLabel.text = cellViewModel.senderName + + let authorLabelAvailableWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 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 = (cellViewModel.senderName != nil ? authorLabelSize.height : 0) + // Message status image view - let (image, tintColor, backgroundColor) = getMessageStatusImage(for: message) + let (image, tintColor, backgroundColor) = getMessageStatusImage(for: cellViewModel) messageStatusImageView.image = image messageStatusImageView.tintColor = tintColor messageStatusImageView.backgroundColor = backgroundColor - if let message = message as? TSOutgoingMessage { - messageStatusImageView.isHidden = (message.isCallMessage || 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 = ( + cellViewModel.variant != .standardOutgoing || + cellViewModel.variant == .infoCall || + ( + cellViewModel.state == .sent && + !cellViewModel.isLast + ) + ) + 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 + let expiresStartedAtMs: Double = cellViewModel.expiresStartedAtMs, + let expiresInSeconds: TimeInterval = cellViewModel.expiresInSeconds + { + let expirationTimestampMs: Double = (expiresStartedAtMs + (expiresInSeconds * 1000)) + + timerView.configure( + withExpirationTimestamp: UInt64(floor(expirationTimestampMs)), + initialDurationSeconds: UInt32(floor(expiresInSeconds)), + tintColor: Colors.text + ) + timerView.isHidden = false } - timerView.isHidden = !viewItem.isExpiringMessage - timerViewOutgoingMessageConstraint.isActive = (direction == .outgoing) - timerViewIncomingMessageConstraint.isActive = (direction == .incoming) + else { + timerView.isHidden = true + } + + timerViewOutgoingMessageConstraint.isActive = (cellViewModel.variant == .standardOutgoing) + timerViewIncomingMessageConstraint.isActive = ( + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardIncomingDeleted + ) + // Swipe to reply - if (message.isDeleted || message.isCallMessage) { + if cellViewModel.variant == .standardIncomingDeleted || cellViewModel.variant == .infoCall { removeGestureRecognizer(panGestureRecognizer) - } else { + } + else { addGestureRecognizer(panGestureRecognizer) } } - - private func populateHeader(for viewItem: ConversationViewItem) { - guard viewItem.shouldShowDate else { return } - let dateBreakLabel = UILabel() + + 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 date = viewItem.interaction.dateForUI() - let description = DateUtil.formatDate(forDisplay: date) - dateBreakLabel.text = description + dateBreakLabel.text = date.formattedForDisplay 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: cellViewModel) 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 cellViewModel: MessageViewModel, + mediaCache: NSCache, + playbackInfo: ConversationViewModel.PlaybackInfo?, + lastSearchText: String? + ) { + let bodyLabelTextColor: UIColor = { + let direction: Direction = (cellViewModel.variant == .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.layer.mask = bubbleViewMaskLayer - 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) - openGroupInvitationView.layer.mask = bubbleViewMaskLayer - 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 cellViewModel.variant != .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 cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { + let mediaPlaceholderView = MediaPlaceholderView(cellViewModel: cellViewModel, textColor: bodyLabelTextColor) + snContentView.addSubview(mediaPlaceholderView) + mediaPlaceholderView.pin(to: snContentView) + return + } + + switch cellViewModel.cellType { + case .typingIndicator: break + + case .textOnlyMessage: + let inset: CGFloat = 12 + let maxWidth: CGFloat = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 2 * inset) + + 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: cellViewModel.linkPreviewAttachment + ), + isOutgoing: (cellViewModel.variant == .standardOutgoing), + delegate: self, + cellViewModel: cellViewModel, + bodyLabelTextColor: bodyLabelTextColor, + lastSearchText: lastSearchText + ) + snContentView.addSubview(linkPreviewView) + linkPreviewView.pin(to: snContentView) + self.bodyTextView = linkPreviewView.bodyTextView + + case .openGroupInvitation: + let openGroupInvitationView: OpenGroupInvitationView = OpenGroupInvitationView( + name: (linkPreview.title ?? ""), + url: linkPreview.url, + textColor: bodyLabelTextColor, + isOutgoing: (cellViewModel.variant == .standardOutgoing) + ) + + snContentView.addSubview(openGroupInvitationView) + openGroupInvitationView.pin(to: snContentView) + } } - // Body text view - let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, 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, - Storage.shared.getContact(with: thread.contactSessionID())?.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 = cellViewModel.quote { + let hInset: CGFloat = 2 + let quoteView: QuoteView = QuoteView( + for: .regular, + authorId: quote.authorId, + quotedText: quote.body, + threadVariant: cellViewModel.threadVariant, + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, + direction: (cellViewModel.variant == .standardOutgoing ? + .outgoing : + .incoming + ), + attachment: cellViewModel.quoteAttachment, + 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: cellViewModel, + 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: cellViewModel) + let albumView = MediaAlbumView( + mediaCache: mediaCache, + items: (cellViewModel.attachments? + .filter { $0.isVisualMedia }) + .defaulting(to: []), + isOutgoing: (cellViewModel.variant == .standardOutgoing), + maxMessageWidth: maxMessageWidth + ) self.albumView = albumView - let size = getSize(for: viewItem) + let size = getSize(for: cellViewModel) 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 = cellViewModel.body, !body.isEmpty { let inset: CGFloat = 12 - let maxWidth = size.width - 2 * inset - let bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, delegate: self) + let maxWidth: CGFloat = (size.width - (2 * inset)) + 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))) } unloadContent = { albumView.unloadMedia() } + // Constraints snContentView.addSubview(stackView) stackView.pin(to: snContentView) - } - case .audio: - if viewItem.interaction is TSIncomingMessage, - let thread = thread as? TSContactThread, - Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true { - showMediaPlaceholder() - } else { - let voiceMessageView = VoiceMessageView(viewItem: viewItem) + + case .audio: + guard let attachment: Attachment = cellViewModel.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, - Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true { - showMediaPlaceholder() - } else { + self.voiceMessageView = voiceMessageView + + case .genericAttachment: + guard let attachment: Attachment = cellViewModel.attachments?.first else { preconditionFailure() } + let inset: CGFloat = 12 - let maxWidth = VisibleMessageCell.getMaxWidth(for: viewItem) - 2 * inset + let maxWidth = (VisibleMessageCell.getMaxWidth(for: cellViewModel) - 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 bodyTextView = VisibleMessageCell.getBodyTextView(for: viewItem, with: maxWidth, textColor: bodyLabelTextColor, 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) } + // 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)) - bubbleViewMaskLayer.path = maskPath.cgPath + let cornersToRound: UIRectCorner = getCornersToRound() + + bubbleBackgroundView.layer.cornerRadius = VisibleMessageCell.largeCornerRadius + bubbleBackgroundView.layer.maskedCorners = getCornerMask(from: cornersToRound) bubbleView.layer.cornerRadius = VisibleMessageCell.largeCornerRadius bubbleView.layer.maskedCorners = getCornerMask(from: cornersToRound) } + 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 + if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted { + return + } + + switch cellViewModel.cellType { + case .audio: + guard let attachment: Attachment = cellViewModel.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 } + viewsToMoveForReply.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) @@ -470,253 +646,357 @@ 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 - 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) - } + // FIXME: This will have issues with themes + let shawdowColour = (isLightMode ? UIColor.black.cgColor : Colors.accent.cgColor) + let opacity: Float = (isLightMode ? 0.5 : 1) + + 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 + } + ) } } - + @objc func handleLongPress() { - guard let viewItem = viewItem else { return } - delegate?.handleViewItemLongPressed(viewItem) + guard let cellViewModel: MessageViewModel = self.viewModel else { return } + + delegate?.handleItemLongPressed(cellViewModel) } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard let viewItem = viewItem else { return } + guard let cellViewModel: MessageViewModel = self.viewModel 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), cellViewModel.shouldShowProfile { + // For open groups only attempt to start a conversation if the author has a blinded id + guard cellViewModel.threadVariant != .openGroup else { + guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return } + + delegate?.startThread( + with: cellViewModel.authorId, + openGroupServer: cellViewModel.threadOpenGroupServer, + openGroupPublicKey: cellViewModel.threadOpenGroupPublicKey + ) + return + } + + delegate?.startThread( + with: cellViewModel.authorId, + openGroupServer: nil, + openGroupPublicKey: nil + ) + } + 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(cellViewModel, gestureRecognizer: gestureRecognizer) } } @objc private func handleDoubleTap() { - guard let viewItem = viewItem else { return } - delegate?.handleViewItemDoubleTapped(viewItem) + guard let cellViewModel: MessageViewModel = self.viewModel else { return } + + delegate?.handleItemDoubleTapped(cellViewModel) } - + @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard let viewItem = viewItem else { return } - let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] + guard let cellViewModel: MessageViewModel = self.viewModel else { return } + 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(cellViewModel, 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 + viewsToMoveForReply.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(cellViewModel, state: .ended) + reply() + } + else { + delegate?.handleItemSwiped(cellViewModel, 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 } - private func resetReply() { - let viewsToMove = [ bubbleView, profilePictureView, replyButton, timerView, messageStatusImageView ] - UIView.animate(withDuration: 0.25) { - viewsToMove.forEach { $0.transform = .identity } - self.replyButton.alpha = 0 + 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 reply() { - guard let viewItem = viewItem else { return } - resetReply() - delegate?.handleReplyButtonTapped(for: viewItem) - } - func handleLinkPreviewCanceled() { - // Not relevant in this case - } - - // 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 + private func resetReply() { + UIView.animate(withDuration: 0.25) { [weak self] in + self?.viewsToMoveForReply.forEach { $0.transform = .identity } + self?.replyButton.alpha = 0 + } + } + + private func reply() { + guard let cellViewModel: MessageViewModel = self.viewModel else { return } + + resetReply() + delegate?.handleReplyButtonTapped(for: cellViewModel) + } + + // MARK: - Convenience + + private func getCornersToRound() -> UIRectCorner { + guard viewModel?.isOnlyMessageInCluster == false else { return .allCorners } + + let direction: Direction = (viewModel?.variant == .standardOutgoing ? .outgoing : .incoming) + + switch (viewModel?.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 cellViewModel: MessageViewModel) -> 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 } } - - 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 cellViewModel: MessageViewModel) -> (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 - let status = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: message) - switch status { - case .uploading, .sending: + switch (cellViewModel.state, cellViewModel.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 cellViewModel: MessageViewModel) -> CGSize { + guard let mediaAttachments: [Attachment] = cellViewModel.attachments?.filter({ $0.isVisualMedia }) else { + preconditionFailure() + } + + let maxMessageWidth = VisibleMessageCell.getMaxWidth(for: cellViewModel) + 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 cellViewModel: MessageViewModel) -> CGFloat { + let screen: CGRect = UIScreen.main.bounds + + switch cellViewModel.variant { + case .standardOutgoing: return (screen.width - contactThreadHSpacing - gutterSize) + case .standardIncoming, .standardIncomingDeleted: + let isGroupThread = ( + cellViewModel.threadVariant == .openGroup || + cellViewModel.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, delegate: UITextViewDelegate & BodyTextViewDelegate) -> UITextView { + static func getBodyTextView( + for cellViewModel: MessageViewModel, + 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 = (cellViewModel.variant == .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: (cellViewModel.body ?? ""), + threadVariant: cellViewModel.threadVariant, + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, + isOutgoingMessage: isOutgoing, + attributes: [ + .foregroundColor : textColor, + .font : UIFont.systemFont(ofSize: getFontSize(for: cellViewModel)) + ] + ) + ) + + // 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() + + SessionThreadViewModel.searchTermParts(searchText) + .map { part -> String in + guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } + + return String(part[part.index(after: part.startIndex).. #import -#import -#import -#import #import #import -#import -#import -#import -#import -#import -#import @import ContactsUI; @import PromiseKit; @@ -30,12 +21,18 @@ CGFloat kIconViewLength = 24; @interface OWSConversationSettingsViewController () -@property (nonatomic) TSThread *thread; -@property (nonatomic) YapDatabaseConnection *uiDatabaseConnection; -@property (nonatomic, readonly) YapDatabaseConnection *editingDatabaseConnection; +@property (nonatomic) NSString *threadId; +@property (nonatomic) NSString *threadName; +@property (nonatomic) BOOL isNoteToSelf; +@property (nonatomic) BOOL isClosedGroup; +@property (nonatomic) BOOL isOpenGroup; @property (nonatomic) NSArray *disappearingMessagesDurations; -@property (nonatomic) OWSDisappearingMessagesConfiguration *disappearingMessagesConfiguration; -@property (nullable, nonatomic) MediaGallery *mediaGallery; + +@property (nonatomic) BOOL originalIsDisappearingMessagesEnabled; +@property (nonatomic) NSInteger originalDisappearingMessagesDurationIndex; +@property (nonatomic) BOOL isDisappearingMessagesEnabled; +@property (nonatomic) NSInteger disappearingMessagesDurationIndex; + @property (nonatomic, readonly) UIImageView *avatarView; @property (nonatomic, readonly) UILabel *disappearingMessagesDurationLabel; @property (nonatomic) UILabel *displayNameLabel; @@ -56,8 +53,6 @@ CGFloat kIconViewLength = 24; return self; } - [self commonInit]; - return self; } @@ -68,8 +63,6 @@ CGFloat kIconViewLength = 24; return self; } - [self commonInit]; - return self; } @@ -80,95 +73,24 @@ CGFloat kIconViewLength = 24; return self; } - [self commonInit]; - return self; } -- (void)commonInit -{ - - [self observeNotifications]; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -#pragma mark - Dependencies - -- (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - -- (OWSProfileManager *)profileManager -{ - return [OWSProfileManager sharedManager]; -} - #pragma mark -- (void)observeNotifications -{ - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(identityStateDidChange:) - name:kNSNotificationName_IdentityStateDidChange - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(otherUsersProfileDidChange:) - name:kNSNotificationName_OtherUsersProfileDidChange - object:nil]; -} - -- (YapDatabaseConnection *)editingDatabaseConnection -{ - return [OWSPrimaryStorage sharedManager].dbReadWriteConnection; -} - -- (nullable NSString *)threadName -{ - 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"; - } else if (threadName.length == 0 && [self isGroupThread]) { - threadName = [MessageStrings newGroupDefaultTitle]; +- (void)configureWithThreadId:(NSString *)threadId threadName:(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; } - 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 @@ -211,7 +133,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"; @@ -220,46 +142,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.disappearingMessagesConfiguration = - [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; - - if (!self.disappearingMessagesConfiguration) { - self.disappearingMessagesConfiguration = - [[OWSDisappearingMessagesConfiguration alloc] initDefaultWithThreadId:self.thread.uniqueId]; - } - - [self updateTableContents]; + 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 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]; } } @@ -269,8 +187,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]; @@ -279,7 +195,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", "") @@ -300,7 +216,7 @@ CGFloat kIconViewLength = 24; } actionBlock:^{ [weakSelf showMediaGallery]; }]]; - + // Invite button if (self.isOpenGroup) { [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ @@ -325,9 +241,9 @@ CGFloat kIconViewLength = 24; } actionBlock:^{ [weakSelf tappedConversationSearch]; }]]; - + // 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; @@ -337,7 +253,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]; @@ -348,7 +264,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]; @@ -361,11 +277,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 = [[LKStorage.shared getContactWithSessionID:thread.contactSessionID] displayNameFor:SNContactContextRegular] ?: @"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; @@ -385,7 +300,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; @@ -415,7 +330,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]; @@ -423,7 +338,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( @@ -438,11 +353,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") @@ -465,8 +379,8 @@ CGFloat kIconViewLength = 24; [weakSelf didTapLeaveGroup]; }]]; } - - if (!isNoteToSelf) { + + if (!self.isNoteToSelf) { // Notification sound [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ UITableViewCell *cell = @@ -493,8 +407,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"); @@ -504,11 +418,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]; @@ -527,7 +441,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]; @@ -557,7 +471,7 @@ CGFloat kIconViewLength = 24; return cell; } customRowHeight:UITableViewAutomaticDimension actionBlock:nil]]; } - + // Mute thread [section addItem:[OWSTableItem itemWithCustomCellBlock:^{ OWSConversationSettingsViewController *strongSelf = weakSelf; @@ -570,7 +484,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:) @@ -580,9 +494,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]; } @@ -594,7 +508,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; @@ -681,36 +595,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; } @@ -749,48 +663,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.enabled; - 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 @@ -811,9 +718,8 @@ CGFloat kIconViewLength = 24; - (BOOL)hasLeftGroup { - if (self.isGroupThread) { - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - return !groupThread.isCurrentUserMemberInGroup; + if (self.isClosedGroup) { + return ![SMKGroupMember isCurrentUserMemberOf:self.threadId]; } return NO; @@ -821,13 +727,8 @@ CGFloat kIconViewLength = 24; - (void)leaveGroup { - TSGroupThread *gThread = (TSGroupThread *)self.thread; - - if (gThread.isClosedGroup) { - NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:gThread.groupModel.groupId]; - [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) { - [[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey using:transaction] retainUntilComplete]; - }]; + if (self.isClosedGroup) { + [[SMKMessageSender leaveClosedGroupWithPublicKey:self.threadId] retainUntilComplete]; } [self.navigationController popViewControllerAnimated:YES]; @@ -846,13 +747,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]; } } @@ -861,13 +758,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) { @@ -875,15 +771,15 @@ 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]; + [SMKMessageSender forceSyncConfigurationNow]; } [weakSelf updateTableContents]; @@ -894,15 +790,15 @@ 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]; + [SMKMessageSender forceSyncConfigurationNow]; } [weakSelf updateTableContents]; @@ -912,7 +808,7 @@ CGFloat kIconViewLength = 24; - (void)toggleDisappearingMessages:(BOOL)flag { - self.disappearingMessagesConfiguration.enabled = flag; + self.isDisappearingMessagesEnabled = flag; [self updateTableContents]; } @@ -920,21 +816,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"); } @@ -945,30 +843,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]; } @@ -977,13 +861,8 @@ CGFloat kIconViewLength = 24; { OWSLogDebug(@""); - MediaGallery *mediaGallery = [[MediaGallery alloc] initWithThread:self.thread - options:MediaGalleryOptionSliderEnabled]; - - 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 @@ -995,9 +874,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 @@ -1013,9 +891,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; @@ -1029,18 +907,10 @@ CGFloat kIconViewLength = 24; - (void)saveName { - 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]; - } + if (self.isClosedGroup || self.isOpenGroup) { return; } + 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; + self.displayNameLabel.text = [SMKProfile displayNameAfterSavingNickname:text forProfileId:self.threadId]; [self hideEditNameUI]; } @@ -1069,23 +939,16 @@ CGFloat kIconViewLength = 24; #pragma mark - Notifications -- (void)identityStateDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self updateTableContents]; -} - +// FIXME: When this screen gets refactored, make sure to observe changes for relevant profile image updates - (void)otherUsersProfileDidChange:(NSNotification *)notification { - 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]] && - [((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/OWSConversationSettingsViewDelegate.h b/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h index 6e421cfa4..b9fcaa2c0 100644 --- a/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h +++ b/Session/Conversations/Settings/OWSConversationSettingsViewDelegate.h @@ -9,11 +9,8 @@ NS_ASSUME_NONNULL_BEGIN @protocol OWSConversationSettingsViewDelegate -- (void)groupWasUpdated:(TSGroupModel *)groupModel; - (void)conversationSettingsDidRequestConversationSearch:(OWSConversationSettingsViewController *)conversationSettingsViewController; -- (void)popAllConversationSettingsViewsWithCompletion:(void (^_Nullable)(void))completionBlock; - @end NS_ASSUME_NONNULL_END 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/BlockedModal.swift b/Session/Conversations/Views & Modals/BlockedModal.swift index 9374f8ffb..ba24745bb 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(id: publicKey) // Title let titleLabel = UILabel() titleLabel.textColor = Colors.text @@ -67,23 +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 - 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) - }, - completion: { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - } - ) + Storage.shared.writeAsync { db in + try Contact + .filter(id: publicKey) + .updateAll(db, Contact.Columns.isBlocked.set(to: false)) + + try MessageSender + .syncConfiguration(db, forceSyncNow: true) + .retainUntilComplete() + } presentingViewController?.dismiss(animated: true, completion: nil) } diff --git a/Session/Conversations/Views & Modals/BodyTextView.swift b/Session/Conversations/Views & Modals/BodyTextView.swift index 3048db56d..358333594 100644 --- a/Session/Conversations/Views & Modals/BodyTextView.swift +++ b/Session/Conversations/Views & Modals/BodyTextView.swift @@ -1,20 +1,37 @@ +// 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 +// • 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? + private let highlightedMentionBackgroundView: HighlightMentionBackgroundView = HighlightMentionBackgroundView() - override var selectedTextRange: UITextRange? { - get { return nil } - set { } + 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) { + init(snDelegate: BodyTextViewDelegate?) { self.snDelegate = snDelegate + super.init(frame: CGRect.zero, textContainer: nil) + + self.clipsToBounds = false // Needed for the 'HighlightMentionBackgroundView' + addSubview(highlightedMentionBackgroundView) + setUpGestureRecognizers() } @@ -35,12 +52,21 @@ final class BodyTextView : UITextView { } @objc private func handleLongPress() { - snDelegate.handleLongPress() + snDelegate?.handleLongPress() } @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/Conversations/Views & Modals/CallModal.swift b/Session/Conversations/Views & Modals/CallModal.swift index d6e512027..1822c5e9a 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 + Storage.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 d8ae3068b..1f337e75d 100644 --- a/Session/Conversations/Views & Modals/ConversationTitleView.swift +++ b/Session/Conversations/Views & Modals/ConversationTitleView.swift @@ -1,26 +1,35 @@ +// 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 { + private static let leftInset: CGFloat = 8 + private static let leftInsetWithCallButton: CGFloat = 54 + 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 }() @@ -29,114 +38,119 @@ final class ConversationTitleView : UIView { result.axis = .vertical result.alignment = .center result.isLayoutMarginsRelativeArrangement = true + 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() { - 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) + // MARK: - Initialization + + init() { + super.init(frame: .zero) - 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() + addSubview(stackView) + + stackView.pin(to: self) } deinit { NotificationCenter.default.removeObserver(self) } - - // 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) + + required init?(coder: NSCoder) { + preconditionFailure("Use init() instead.") } - // 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() - 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) + // 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, + isNoteToSelf: isNoteToSelf, + threadVariant: threadVariant, + mutedUntilTimestamp: mutedUntilTimestamp, + onlyNotifyForMentions: onlyNotifyForMentions, + userCount: userCount + ) } - return result + return } - } - - 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 { + + // Generate the subtitle + let subtitle: NSAttributedString? = { + 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 + 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") - } + 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()) } - } - return nil - } - - // MARK: Interaction - @objc private func handleTap() { - delegate?.handleTitleViewTapped() + 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 + + // 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 + ) } } - -// MARK: Delegate -protocol ConversationTitleViewDelegate : AnyObject { - - func handleTitleViewTapped() -} diff --git a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift index 474ba422c..981c1d42c 100644 --- a/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift +++ b/Session/Conversations/Views & Modals/DownloadAttachmentModal.swift @@ -1,42 +1,58 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class DownloadAttachmentModal : Modal { - private let viewItem: ConversationViewItem +import UIKit +import GRDB +import SessionUIKit +import SessionUtilitiesKit +import SessionMessagingKit + +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 = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + let name: String = profile.displayName() + // Title let titleLabel = UILabel() titleLabel.textColor = Colors.text titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) 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) @@ -45,15 +61,18 @@ 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 + // Content stack view let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ]) contentStackView.axis = .vertical contentStackView.spacing = Values.largeSpacing + // Main stack view let spacing = Values.largeSpacing - Values.smallFontSize / 2 let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) @@ -65,19 +84,37 @@ final class DownloadAttachmentModal : Modal { contentView.pin(.trailing, to: .trailing, of: mainStackView, withInset: Values.largeSpacing) contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) } + + // MARK: - Interaction - // 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) - }) + guard let profileId: String = profile?.id else { return } + + Storage.shared.writeAsync { db in + try Contact + .filter(id: profileId) + .updateAll(db, Contact.Columns.isTrusted.set(to: true)) + + // Start downloading any pending attachments for this contact (UI will automatically be + // updated due to the database observation) + try Attachment + .stateInfo(authorId: profileId, state: .pendingDownload) + .fetchAll(db) + .forEach { attachmentDownloadInfo in + JobRunner.add( + db, + job: Job( + variant: .attachmentDownload, + threadId: profileId, + interactionId: attachmentDownloadInfo.interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: attachmentDownloadInfo.attachmentId + ) + ) + ) + } + } + presentingViewController?.dismiss(animated: true, completion: nil) } } diff --git a/Session/Conversations/Views & Modals/InsetLockableTableView.swift b/Session/Conversations/Views & Modals/InsetLockableTableView.swift new file mode 100644 index 000000000..1f0fb7980 --- /dev/null +++ b/Session/Conversations/Views & Modals/InsetLockableTableView.swift @@ -0,0 +1,80 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +/// 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 { + guard !lockContentOffset else { return } + + self.contentOffset = newOffset + } + } + public var oldOffset: CGPoint = .zero + public var newOffset: CGPoint = .zero + private var callbackCondition: ((Int, [Int], CGSize) -> Bool)? + private var afterLayoutSubviewsCallback: (() -> ())? + + public override func layoutSubviews() { + self.newOffset = self.contentOffset + + // Store the callback locally to prevent infinite loops + var callback: (() -> ())? + + if self.checkCallbackCondition() { + callback = self.afterLayoutSubviewsCallback + self.afterLayoutSubviewsCallback = nil + } + + guard !lockContentOffset else { + self.contentOffset = CGPoint( + x: newOffset.x, + y: oldOffset.y + ) + + super.layoutSubviews() + callback?() + return + } + + super.layoutSubviews() + callback?() + + self.oldOffset = self.contentOffset + } + + // MARK: - Functions + + public func afterNextLayoutSubviews( + when condition: @escaping (Int, [Int], CGSize) -> Bool, + then callback: @escaping () -> () + ) { + self.callbackCondition = condition + self.afterLayoutSubviewsCallback = callback + } + + private func checkCallbackCondition() -> Bool { + guard self.callbackCondition != nil else { return false } + + let numSections: Int = self.numberOfSections + let numRowInSections: [Int] = (0.. 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.mediumFontSize) - 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,18 +49,22 @@ 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 + // Content stack view let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ]) contentStackView.axis = .vertical contentStackView.spacing = Values.largeSpacing + // Main stack view let spacing = Values.largeSpacing - Values.smallFontSize / 2 let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) + mainStackView.axis = .vertical mainStackView.spacing = spacing contentView.addSubview(mainStackView) @@ -62,9 +74,13 @@ final class LinkPreviewModal : Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) } - // MARK: Interaction + // MARK: - Interaction + @objc private func enable() { - SSKPreferences.areLinkPreviewsEnabled = true + Storage.shared.writeAsync { db in + db[.areLinkPreviewsEnabled] = true + } + presentingViewController?.dismiss(animated: true, completion: nil) onLinkPreviewsEnabled() } diff --git a/Session/Conversations/Views & Modals/MessagesTableView.swift b/Session/Conversations/Views & Modals/MessagesTableView.swift deleted file mode 100644 index 583e3c76d..000000000 --- a/Session/Conversations/Views & Modals/MessagesTableView.swift +++ /dev/null @@ -1,24 +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) - register(CallMessageCell.self, forCellReuseIdentifier: CallMessageCell.identifier) - separatorStyle = .none - backgroundColor = .clear - showsVerticalScrollIndicator = false - contentInsetAdjustmentBehavior = .never - keyboardDismissMode = .interactive - } -} 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/URLModal.swift b/Session/Conversations/Views & Modals/URLModal.swift index 8beb2db6a..41e50b606 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.mediumFontSize) 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,16 +49,19 @@ 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 + // Content stack view let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ]) contentStackView.axis = .vertical contentStackView.spacing = Values.largeSpacing + // Main stack view let spacing = Values.largeSpacing - Values.smallFontSize / 2 let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) @@ -64,9 +74,11 @@ final class URLModal : Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) } - // 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/Session/Conversations/Views & Modals/UserDetailsSheet.swift b/Session/Conversations/Views & Modals/UserDetailsSheet.swift index 5afc5518b..4867cd662 100644 --- a/Session/Conversations/Views & Modals/UserDetailsSheet.swift +++ b/Session/Conversations/Views & Modals/UserDetailsSheet.swift @@ -1,9 +1,14 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class UserDetailsSheet : Sheet { - private let sessionID: String +import UIKit +import SessionMessagingKit + +final class UserDetailsSheet: Sheet { + private let profile: Profile - init(for sessionID: String) { - self.sessionID = sessionID + init(for profile: Profile) { + self.profile = profile + super.init(nibName: nil, bundle: nil) } @@ -22,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 = Storage.shared.getContact(with: sessionID)?.displayName(for: .regular) ?? 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 @@ -39,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) @@ -47,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 05ff3f049..b55fbb3df 100644 --- a/Session/DMs/NewDMVC.swift +++ b/Session/DMs/NewDMVC.swift @@ -1,3 +1,10 @@ +// 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 { private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) @@ -71,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 @@ -88,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) @@ -138,38 +134,57 @@ 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? SnodeAPI.Error { - switch error { - case .decryptionFailed, .hashingFailed, .validationFailed: messageOrNil = error.errorDescription + 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) } } } } - private func startNewDM(with sessionID: String) { - let thread = TSContactThread.getOrCreateThread(contactSessionID: sessionID) + private func startNewDM(with sessionId: String) { + let maybeThread: SessionThread? = Storage.shared.write { db in + try SessionThread.fetchOrCreate(db, id: sessionId, 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: sessionId, action: .compose, animated: false) } } diff --git a/Session/Home/GlobalSearch/EmptySearchResultCell.swift b/Session/Home/GlobalSearch/EmptySearchResultCell.swift index 45402292a..10009dbd4 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 @@ -24,6 +26,7 @@ class EmptySearchResultCell: UITableViewCell { super.init(style: style, reuseIdentifier: reuseIdentifier) backgroundColor = .clear + selectionStyle = .none contentView.addSubview(messageLabel) messageLabel.autoSetDimension(.height, toSize: 150) diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 3ecc63b20..c88b527be 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -1,11 +1,42 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import GRDB +import DifferenceKit +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit +import SignalUtilitiesKit -@objc class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { + fileprivate typealias SectionModel = ArraySection - let isRecentSearchResultsEnabled = false + // MARK: - SearchSection + + enum SearchSection: Int, Differentiable { + case noResults + case contactsAndGroups + case messages + } + + // MARK: - Variables + + private lazy var defaultSearchResults: [SectionModel] = { + let result: SessionThreadViewModel? = Storage.shared.read { db -> SessionThreadViewModel? in + try SessionThreadViewModel + .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 { @@ -14,55 +45,37 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo refreshSearchResults() } } - var recentSearchResults: [String] = Array(Storage.shared.getRecentSearchResults().reversed()) - var defaultSearchResults: HomeScreenSearchResultSet = HomeScreenSearchResultSet.noteToSelfOnly - var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty - private var lastSearchText: String? - var searcher: FullTextSearcher { - return FullTextSearcher.shared - } - var isLoading = false - enum SearchSection: Int { - case noResults - case contacts - 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: FullConversationCell.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) @@ -74,22 +87,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 @@ -103,37 +116,35 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo if UIDevice.current.isIPad { let ipadCancelButton = UIButton() ipadCancelButton.setTitle("Cancel", for: .normal) - ipadCancelButton.addTarget(self, action: #selector(cancel(_:)), for: .touchUpInside) + ipadCancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside) ipadCancelButton.setTitleColor(Colors.text, for: .normal) searchBarContainer.addSubview(ipadCancelButton) ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer) ipadCancelButton.autoVCenterInSuperview() searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing) searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing) - } else { + } + else { searchBar.autoPinEdgesToSuperviewMargins() } } - + private func reloadTableData() { tableView.reloadData() } - - // MARK: Update Search Results - var refreshTimer: Timer? - + // MARK: - Update Search Results + 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) { + private func updateSearchResults(searchText rawSearchText: String) { let searchText = rawSearchText.stripped + guard searchText.count > 0 else { searchResultSet = defaultSearchResults lastSearchText = nil @@ -144,56 +155,81 @@ 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 - }) + let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in + do { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel + .contactsAndGroupsQuery( + userPublicKey: userPublicKey, + pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText), + searchTerm: searchText + ) + .fetchAll(db) + let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel + .messagesQuery( + userPublicKey: userPublicKey, + pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText) + ) + .fetchAll(db) + + return .success([ + ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults), + ArraySection(model: .messages, elements: messageResults) + ]) + } + 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: [SessionThreadViewModel(unreadCount: 0)])]), + (hasResults ? sections : nil) + ] + .compactMap { $0 } + .flatMap { $0 } + self.isLoading = false + self.reloadTableData() + self.refreshTimer = nil + + default: break + } } - // MARK: Interaction - @objc func clearRecentSearchResults() { - recentSearchResults = [] - tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top) - Storage.shared.clearRecentSearchResults() - } - - @objc func cancel(_ sender: Any) { + @objc func cancel() { self.navigationController?.popViewController(animated: true) } - } // 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 @@ -201,53 +237,59 @@ 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) - } - } - - 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) - } - 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) + let section: SectionModel = self.searchResultSet[indexPath.section] + + switch section.model { + case .noResults: break + case .contactsAndGroups, .messages: + show( + threadId: section.elements[indexPath.row].threadId, + threadVariant: section.elements[indexPath.row].threadVariant, + focusedInteractionId: section.elements[indexPath.row].interactionId + ) } } - // MARK: UITableViewDataSource - + 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, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId, animated: animated) + } + return + } + + if let presentedVC = self.presentedViewController { + presentedVC.dismiss(animated: false, completion: nil) + } + + let viewControllers: [UIViewController] = (self.navigationController? + .viewControllers) + .defaulting(to: []) + .appending( + ConversationVC(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId) + ) + + self.navigationController?.setViewControllers(viewControllers, animated: true) + } + + // MARK: - UITableViewDataSource + public func numberOfSections(in tableView: UITableView) -> Int { - return 4 + 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() } @@ -260,79 +302,36 @@ 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 - } - } - } - - 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 + let section: SectionModel = 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()) } } @@ -341,41 +340,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: SectionModel = 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: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath) + cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet) + return cell + + case .messages: + 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/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 f31441602..f45a9ece4 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -1,39 +1,43 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -// 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 +import UIKit +import GRDB +import DifferenceKit +import SessionMessagingKit +import SessionUtilitiesKit +import SignalUtilitiesKit + +final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate { + private static let loadingHeaderHeight: CGFloat = 20 + + private let viewModel: HomeViewModel = HomeViewModel() + private var dataChangeObservable: DatabaseCancellable? + private var hasLoadedInitialStateData: Bool = false + private var hasLoadedInitialThreadData: Bool = false + private var isLoadingMore: Bool = false + private var isAutoLoadingNextPage: Bool = false + private var viewHasAppeared: Bool = false + + // MARK: - Intialization + + init() { + Storage.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 + 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%" @@ -43,6 +47,19 @@ 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 + result.isHidden = !self.viewModel.state.showViewedSeedBanner + + 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 }() @@ -50,11 +67,27 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv let result = UITableView() result.backgroundColor = .clear result.separatorStyle = .none - 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.contentInset = UIEdgeInsets( + top: 0, + left: 0, + bottom: ( + Values.newConversationButtonBottomOffset + + NewConversationButtonSet.expandedButtonSize + + Values.largeSpacing + + NewConversationButtonSet.collapsedButtonSize + ), + right: 0 + ) result.showsVerticalScrollIndicator = false + result.register(view: MessageRequestsCell.self) + result.register(view: FullConversationCell.self) + result.dataSource = self + result.delegate = self + + if #available(iOS 15.0, *) { + result.sectionHeaderTopPadding = 0 + } + return result }() @@ -69,6 +102,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv let gradient = Gradients.homeVCFade result.setGradient(gradient) result.isUserInteractionEnabled = false + return result }() @@ -89,22 +123,23 @@ 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 + SessionApp.homeViewController.mutate { $0 = self } + // Gradient & nav bar setUpGradientBackground() if navigationController?.navigationBar != nil { @@ -112,23 +147,28 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } updateNavBarButtons() 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) + + 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 - tableView.dataSource = self - tableView.delegate = self 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 { - tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) + } + else { + tableViewTopConstraint = tableView.pin(.top, to: .top, of: view) } tableView.pin(.trailing, to: .trailing, of: view) tableView.pin(.bottom, to: .bottom, of: view) @@ -138,272 +178,211 @@ 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: 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(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil) - notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, 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 - } + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidResignActive(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil + ) + // Start polling if needed (i.e. if the user just created or restored their Session ID) - if OWSIdentityManager.shared().identityKeyPair() != nil { - 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 viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + startObservingChanges() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - reload() - } - - override func appDidBecomeActive(_ notification: Notification) { - reload() - } - - 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 - - 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 + self.viewHasAppeared = true + self.autoLoadNextPageIfNeeded() } - @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) + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stopObservingChanges() + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges(didReturnFromBackground: true) + } + + @objc func applicationDidResignActive(_ notification: Notification) { + stopObservingChanges() + } + + // MARK: - Updating + + private func startObservingChanges(didReturnFromBackground: Bool = false) { + // Start observing for data changes + 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 + // the old behaviour) + scheduling: (hasLoadedInitialStateData ? + .async(onQueue: .main) : + .immediate + ), + onError: { _ in }, + onChange: { [weak self] state in + // The default scheduler emits changes on the main thread + self?.handleUpdates(state) + } ) - 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() - } + self.viewModel.onThreadChange = { [weak self] updatedThreadData in + self?.handleThreadUpdates(updatedThreadData) } - var sectionChanges = NSArray() - var rowChanges = NSArray() - ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads) + // 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() { + // 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 hasLoadedInitialStateData else { + hasLoadedInitialStateData = true + UIView.performWithoutAnimation { handleUpdates(updatedState, initialLoad: true) } + return + } - // 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 } + if updatedState.userProfile != self.viewModel.state.userProfile { + updateNavBarButtons() + } - 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 { + // Update the 'view seed' UI + if updatedState.showViewedSeedBanner != self.viewModel.state.showViewedSeedBanner { + tableViewTopConstraint.isActive = false + seedReminderView.isHidden = !updatedState.showViewedSeedBanner - // 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) + if updatedState.showViewedSeedBanner { + tableViewTopConstraint = tableView.pin(.top, to: .bottom, of: seedReminderView) } 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) - } + tableViewTopConstraint = tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) } } - inboxRowChanges.forEach { rowChange in - let key = rowChange.collectionKey.key - threadViewModelCache[key] = nil + 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 + self?.autoLoadNextPageIfNeeded() + } + + // Reload the table content (animate changes after the first load) + tableView.reload( + using: StagedChangeset(source: viewModel.threadData, target: updatedData), + deleteSectionsAnimation: .none, + insertSectionsAnimation: .none, + reloadSectionsAnimation: .none, + deleteRowsAnimation: .bottom, + insertRowsAnimation: .none, + reloadRowsAnimation: .none, + interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues + ) { [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 - 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 + // 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) } } - 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) { - tableView.reloadData() // TODO: Just reload the affected cell - } - - @objc private func handleLocalProfileDidChangeNotification(_ notification: Notification) { - 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 } private func updateNavBarButtons() { @@ -412,17 +391,23 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv 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) + 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" @@ -447,25 +432,114 @@ 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.threadData.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + let section: HomeViewModel.SectionModel = viewModel.threadData[section] + + return section.elements.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + 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(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: 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.viewHasAppeared && !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) - switch indexPath.section { - case 0: + let section: HomeViewModel.SectionModel = self.viewModel.threadData[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 threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + show( + threadViewModel.threadId, + variant: threadViewModel.threadVariant, + isMessageRequest: (threadViewModel.threadIsMessageRequest == true), + with: .none, + focusedInteractionId: nil, + animated: true + ) + + default: break } } @@ -474,101 +548,111 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - switch indexPath.section { - case 0: - let hide = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_HIDE_TITLE", comment: "")) { [weak self] _, _ in - CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = true - - // Animate the row removal - self?.tableView.beginUpdates() - self?.tableView.deleteRows(at: [indexPath], with: .automatic) - self?.tableView.endUpdates() + let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section] + + switch section.model { + case .messageRequests: + let hide = UITableViewRowAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _ in + Storage.shared.write { db in db[.hasHiddenMessageRequests] = true } } hide.backgroundColor = Colors.destructive 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 threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + let delete: UITableViewRowAction = UITableViewRowAction( + style: .destructive, + title: "TXT_DELETE_TITLE".localized() + ) { [weak self] _, _ in + let message = (threadViewModel.currentUserIsClosedGroupAdmin == true ? + "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 + Storage.shared.writeAsync { db in + switch threadViewModel.threadVariant { + case .closedGroup: + try MessageSender + .leave(db, groupPublicKey: threadViewModel.threadId) + .retainUntilComplete() + + case .openGroup: + OpenGroupManager.shared.delete(db, openGroupId: threadViewModel.threadId) + + default: break + } + + _ = try SessionThread + .filter(id: threadViewModel.threadId) + .deleteAll(db) + } }) - alert.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .default) { _ in }) - guard let self = self else { return } - self.presentAlert(alert) + 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 - 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) - }, - completion: { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - - DispatchQueue.main.async { - tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) - } - } - ) + let pin: UITableViewRowAction = UITableViewRowAction( + style: .normal, + title: (threadViewModel.threadIsPinned ? + "UNPIN_BUTTON_TEXT".localized() : + "PIN_BUTTON_TEXT".localized() + ) + ) { _, _ in + Storage.shared.writeAsync { db in + try SessionThread + .filter(id: threadViewModel.threadId) + .updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned)) } - 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) - }, - completion: { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - - DispatchQueue.main.async { - tableView.reloadRows(at: [ indexPath ], with: UITableView.RowAnimation.fade) - } - } - ) + } + + guard threadViewModel.threadVariant == .contact && !threadViewModel.threadIsNoteToSelf else { + return [ delete, pin ] + } + + let block: UITableViewRowAction = UITableViewRowAction( + style: .normal, + title: (threadViewModel.threadIsBlocked == true ? + "BLOCK_LIST_UNBLOCK_BUTTON".localized() : + "BLOCK_LIST_BLOCK_BUTTON".localized() + ) + ) { _, _ in + Storage.shared.writeAsync { db in + try Contact + .filter(id: threadViewModel.threadId) + .updateAll( + db, + Contact.Columns.isBlocked.set( + to: (threadViewModel.threadIsBlocked == false ? + true: + false + ) + ) + ) + + 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 ] + + default: return [] } } @@ -580,33 +664,29 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv 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) - } - } - - 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) - } + func show( + _ threadId: String, + variant: SessionThread.Variant, + isMessageRequest: Bool, + with action: ConversationViewModel.Action, + focusedInteractionId: Int64?, + animated: Bool + ) { + if let presentedVC = self.presentedViewController { + presentedVC.dismiss(animated: false, completion: nil) } + + 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() { @@ -625,11 +705,13 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } @objc func joinOpenGroup() { - let joinOpenGroupVC = JoinOpenGroupVC() - let navigationController = OWSNavigationController(rootViewController: joinOpenGroupVC) + let joinOpenGroupVC: JoinOpenGroupVC = JoinOpenGroupVC() + let navigationController: OWSNavigationController = OWSNavigationController(rootViewController: joinOpenGroupVC) + if UIDevice.current.isIPad { navigationController.modalPresentationStyle = .fullScreen } + present(navigationController, animated: true, completion: nil) } @@ -660,29 +742,4 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } 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 new file mode 100644 index 000000000..a6c99443b --- /dev/null +++ b/Session/Home/HomeViewModel.swift @@ -0,0 +1,302 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 = 15 + + public struct State: Equatable { + let showViewedSeedBanner: Bool + let hasHiddenMessageRequests: Bool + let unreadMessageRequestThreadCount: Int + let userProfile: Profile? + + init( + showViewedSeedBanner: Bool = !Storage.shared[.hasViewedSeed], + hasHiddenMessageRequests: Bool = Storage.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 = Storage.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 + + /// 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:** 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 observableState = ValueObservation + .trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) } + .removeDuplicates() + + 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 + ) + 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 + (finalUnreadMessageRequestCount == 0 ? + [] : + [SectionModel( + section: .messageRequests, + elements: [ + SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount)) + ] + )] + ), + [ + 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 } + + 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 ? + [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 041ee1fd0..ba87a80c3 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -1,49 +1,60 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. 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 static let loadingHeaderHeight: CGFloat = 20 - private var messageRequestCount: UInt { - threads.numberOfItems(inGroup: TSMessageRequestGroup) + private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel() + 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 + + init() { + Storage.shared.addObserver(viewModel.pagedDataObserver) + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init() instead.") } - private lazy var dbConnection: YapDatabaseConnection = { - let result = OWSPrimaryStorage.shared().newDatabaseConnection() - result.objectCacheLimit = 500 - - return result - }() + deinit { + NotificationCenter.default.removeObserver(self) + } // 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: FullConversationCell.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 + if #available(iOS 15.0, *) { + result.sectionHeaderTopPadding = 0 + } + return result }() - + private lazy var emptyStateLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false @@ -54,19 +65,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 @@ -78,17 +89,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) @@ -96,58 +111,69 @@ 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: NSNotification.Name(rawValue: kNSNotificationName_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 ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) - reload() + startObservingChanges() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - reload() + + self.viewHasAppeared = true + self.autoLoadNextPageIfNeeded() } - deinit { - NotificationCenter.default.removeObserver(self) + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop observing database changes + dataChangeObservable?.cancel() } + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges(didReturnFromBackground: true) + } + + @objc func applicationDidResignActive(_ notification: Notification) { + // Stop observing database changes + dataChangeObservable?.cancel() + } + // 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, @@ -158,277 +184,285 @@ 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) + 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() } - threadViewModelCache.removeAll() - tableView.reloadData() - clearAllButton.isHidden = (messageRequestCount == 0) - emptyStateLabel.isHidden = (messageRequestCount != 0) } - @objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) { - // NOTE: This code is very finicky and crashes easily. Modify with care. - AssertIsOnMainThread() + 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 hasLoadedInitialThreadData else { + hasLoadedInitialThreadData = true + UIView.performWithoutAnimation { handleThreadUpdates(updatedData, initialLoad: true) } + return + } - // 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! + // Show the empty state if there is no data + clearAllButton.isHidden = updatedData.isEmpty + emptyStateLabel.isHidden = !updatedData.isEmpty - // Create a stable state for the connection and jump to the latest commit - let notifications = dbConnection.beginLongLivedReadTransaction() + CATransaction.begin() + CATransaction.setCompletionBlock { [weak self] in + // Complete page loading + self?.isLoadingMore = false + self?.autoLoadNextPageIfNeeded() + } - guard !notifications.isEmpty else { return } + // Reload the table content (animate changes after the first load) + tableView.reload( + using: StagedChangeset(source: viewModel.threadData, target: updatedData), + 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.updateThreadData(updatedData) + } - let ext = dbConnection.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewConnection - let hasChanges = ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications) + CATransaction.commit() + } + + private func autoLoadNextPageIfNeeded() { + guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } - guard hasChanges else { return } + self.isAutoLoadingNextPage = true - if let firstChangeSet = notifications[0].userInfo { - let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64 + DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in + self?.isAutoLoadingNextPage = false - if threads.snapshotOfLastUpdate != firstSnapshot - 1 { - return reload() // The code below will crash if we try to process multiple commits at once - } - } - - var sectionChanges = NSArray() - var rowChanges = NSArray() - ext.getSectionChanges(§ionChanges, rowChanges: &rowChanges, for: notifications, with: threads) - - 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 - } - } - 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 + // 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) + } - switch rowChange.type { - case .move: tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!) - default: break + guard shouldLoadMore else { return } + + self?.isLoadingMore = true + + DispatchQueue.global(qos: .default).async { [weak self] in + self?.viewModel.pagedDataObserver?.load(.pageAfter) } } - - 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 numberOfSections(in tableView: UITableView) -> Int { + return viewModel.threadData.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + let section: MessageRequestsViewModel.SectionModel = viewModel.threadData[section] + + return section.elements.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + 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.viewHasAppeared && !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) - guard let thread = self.thread(at: indexPath.row) else { return } + let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section] - let conversationVC = ConversationVC(thread: thread) - self.navigationController?.pushViewController(conversationVC, animated: true) + 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 { return true } - + func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - guard let thread = self.thread(at: indexPath.row) else { return [] } + let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section] - let delete = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_DELETE_TITLE", comment: "")) { [weak self] _, _ in - self?.delete(thread) + 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 ] + + default: return [] } - delete.backgroundColor = Colors.destructive - - return [ delete ] } - + // MARK: - Interaction - private func updateContactAndThread(thread: TSThread, with transaction: YapDatabaseReadWriteTransaction, onComplete: ((Bool) -> ())? = nil) { - guard let contactThread: TSContactThread = thread as? TSContactThread else { - onComplete?(false) + @objc private func clearAllTapped() { + guard viewModel.threadData.first(where: { $0.model == .threads })?.elements.isEmpty == false else { 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.. 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) + + // 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) + } + + 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 + Storage.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() + } + }) + + 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 new file mode 100644 index 000000000..c19ff8538 --- /dev/null +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -0,0 +1,174 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SignalUtilitiesKit + +public class MessageRequestsViewModel { + public typealias SectionModel = ArraySection + + // 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) + } + } + + // 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 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 ? + [SectionModel(section: .loadMore)] : + [] + ) + ].flatMap { $0 } + } + + public func updateThreadData(_ updatedData: [SectionModel]) { + self.threadData = updatedData + } +} diff --git a/Session/Home/Views/MessageRequestsCell.swift b/Session/Home/Message Requests/Views/MessageRequestsCell.swift similarity index 96% rename from Session/Home/Views/MessageRequestsCell.swift rename to Session/Home/Message Requests/Views/MessageRequestsCell.swift index 54002f721..b2b72f799 100644 --- a/Session/Home/Views/MessageRequestsCell.swift +++ b/Session/Home/Message Requests/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 = (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.unreadCountViewSize), - unreadCountView.heightAnchor.constraint(equalToConstant: ConversationCell.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/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/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index f667a47b7..ce215af26 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) @@ -116,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 @@ -469,8 +458,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect progressiveSearchTimer = nil guard let text = searchBar.text else { - OWSAlerts.showErrorAlert(message: NSLocalizedString("GIF_PICKER_VIEW_MISSING_QUERY", - comment: "Alert message shown when user tries to search for GIFs without entering any search terms.")) + // Alert message shown when user tries to search for GIFs without entering any search terms + OWSAlerts.showErrorAlert(message: "GIF_PICKER_VIEW_MISSING_QUERY".localized()) return } @@ -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/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 233f78d53..2ac147d40 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) @@ -54,12 +57,11 @@ 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 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,24 +71,16 @@ 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() 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 - collectionView.backgroundColor = .white + collectionView.backgroundColor = Colors.navigationBarBackground let selectionPanGesture = DirectionalPanGestureRecognizer(direction: [.horizontal], target: self, action: #selector(didPanSelection)) selectionPanGesture.delegate = self @@ -200,16 +194,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 @@ -268,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) { @@ -343,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 @@ -355,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) @@ -556,11 +536,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) @@ -587,7 +565,7 @@ extension ImagePickerGridController: UIGestureRecognizerDelegate { } } -protocol TitleViewDelegate: class { +protocol TitleViewDelegate: AnyObject { func titleViewWasTapped(_ titleView: TitleView) } @@ -615,10 +593,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/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 8ff20ff70..000000000 --- a/Session/Media Viewing & Editing/MediaDetailViewController.m +++ /dev/null @@ -1,500 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "MediaDetailViewController.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 new file mode 100644 index 000000000..2d89e625d --- /dev/null +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -0,0 +1,444 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 + // Only reload the content if the view has already loaded (if it + // hasn't then it'll load with the image immediately) + 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.") + } + ) + } + + 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() { + 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 + return + } + + let viewSize: CGSize = self.scrollView.bounds.size + + guard imageSize.width > 0 && imageSize.height > 0 else { + SNLog("Invalid image dimensions (\(imageSize.width), \(imageSize.height))") + return + } + + 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 { + 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() + self.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 new file mode 100644 index 000000000..cc425d2d3 --- /dev/null +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -0,0 +1,574 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SignalUtilitiesKit +import SessionUtilitiesKit + +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 + } + + // MARK: - Variables + + public let threadId: String + public let threadVariant: SessionThread.Variant + 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]] = [:] + 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]) -> ())? { + 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 + + init( + threadId: String, + threadVariant: SessionThread.Variant, + isPagedData: Bool, + pageSize: Int = 1, + focusedAttachmentId: String? = nil, + performInitialQuerySync: Bool = false + ) { + self.threadId = threadId + self.threadVariant = threadVariant + self.focusedAttachmentId = focusedAttachmentId + self.pagedDataObserver = nil + + guard isPagedData else { return } + + // 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: Attachment.self, + pageSize: pageSize, + idColumn: .id, + observedChanges: [ + PagedData.ObservedChanges( + table: Attachment.self, + columns: [.isValid] + ) + ], + joinSQL: Item.joinSQL, + filterSQL: Item.filterSQL(threadId: threadId), + orderSQL: Item.galleryOrderSQL, + dataQuery: Item.baseQuery(orderSQL: Item.galleryOrderSQL), + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + guard let updatedGalleryData: [SectionModel] = self?.process(data: updatedData, for: updatedPageInfo) else { + return + } + + // 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) + } + ) + + // Run the initial query on a backgorund thread so we don't block the push transition + 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 { + self?.pagedDataObserver?.load(.pageBefore) + return + } + + 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 + + public struct GalleryDate: Differentiable, Equatable, Comparable, Hashable { + private static let thisYearFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM" + + 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 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) + + 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 + + public var rowId: 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: - Query + + fileprivate static let joinSQL: SQL = { + let attachment: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return """ + JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id]) + JOIN \(Interaction.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId]) + """ + }() + + 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() + + /// **Note:** This **MUST** match the desired sort behaviour for the screen otherwise paging will be + /// very broken + return SQL("\(interaction[.timestampMs].desc), \(interactionAttachment[.albumIndex])") + }() + + fileprivate static let galleryReverseOrderSQL: 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]), \(interactionAttachment[.albumIndex].desc)") + }() + + 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 customFilters: SQL = customFilters else { + return """ + WHERE \(attachment.alias[Column.rowID]) IN \(rowIds) + """ + } + + return """ + WHERE ( + \(customFilters) + ) + """ + }() + 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) + """ + + return request.adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeLinkedRecords, + Attachment.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + Item.attachmentString: adapters[1] + ]) + } + } + } + + fileprivate static func baseQuery(orderSQL: SQL, customFilters: SQL) -> AdaptedFetchRequest> { + return Item.baseQuery(orderSQL: orderSQL, customFilters: customFilters)([]) + } + + 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 + /// + /// **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) + + private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation { + return ValueObservation + .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]), + customFilters: SQL(""" + \(attachment[.isValid]) = true AND + \(interaction[.id]) = \(interactionId) + """) + ) + .fetchAll(db) + } + .removeDuplicates() + } + + @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? = 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 [] } + + // 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 + } + + // 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 + + // 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 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, + isPagedData: false + ) + 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?, + performInitialQuerySync: Bool = false + ) -> MediaTileViewController { + let viewModel: MediaGalleryViewModel = MediaGalleryViewModel( + threadId: threadId, + threadVariant: threadVariant, + isPagedData: true, + pageSize: MediaTileViewController.itemPageSize, + focusedAttachmentId: focusedAttachmentId, + performInitialQuerySync: performInitialQuerySync + ) + + return MediaTileViewController( + viewModel: viewModel + ) + } +} + +// 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, + 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 9cb9dcdd3..7b9f96349 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 = Storage.shared.start( + viewModel.observableAlbumData, + onError: { _ 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, + performInitialQuerySync: true + ) + + 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,50 +500,84 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou owsFailDebug("currentViewController was unexpectedly nil") return } - - let attachmentStream = currentViewController.galleryItem.attachmentStream + guard let originalFilePath: String = currentViewController.galleryItem.attachment.originalFilePath else { + return + } + + let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: originalFilePath) ], applicationActivities: nil) - let shareVC = UIActivityViewController(activityItems: [ attachmentStream.originalMediaURL! ], 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 { + } + else if completed { SNLog("Did share with activityType: \(activityType.debugDescription)") } - 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 activityType = activityType, + activityType == .saveToCameraRoll, + currentViewController.galleryItem.interactionVariant == .standardIncoming, + self.viewModel.threadVariant == .contact + else { return } + + Storage.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 + ) } } self.present(shareVC, animated: true, completion: nil) } - @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 + Storage.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) @@ -430,59 +585,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 @@ -530,8 +650,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou captionContainerView.completePagerTransition() } - updateTitle() - updateMediaRail() + updateTitle(item: currentItem) + updateMediaRail(item: currentItem) previousPage.zoomOut(animated: false) previousPage.stopAnyVideo() updateFooterBarButtonItems(isPlayingVideo: false) @@ -544,139 +664,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.") @@ -687,21 +797,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: - let publicKey = incomingMessage.authorId - let context = Contact.context(for: incomingMessage.thread) - return Storage.shared.getContact(with: publicKey)?.displayName(for: context) ?? publicKey - 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() @@ -757,24 +853,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 Storage.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 @@ -782,24 +894,16 @@ 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 + + func performInteractiveDismissal(animated: Bool) { + dismissSelf(animated: true) } } -extension MediaGalleryItem: GalleryRailItem { +extension MediaGalleryViewModel.Item: GalleryRailItem { public func buildRailItemView() -> UIView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill @@ -813,30 +917,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 + ) } } @@ -862,3 +968,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 6958a2c34..1085b574a 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -1,531 +1,628 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation -import SessionUIKit import UIKit +import QuartzCore +import GRDB +import DifferenceKit +import SessionUIKit +import SignalUtilitiesKit -public protocol MediaTileViewControllerDelegate: class { - func mediaTileViewController(_ viewController: MediaTileViewController, didTapView tappedView: UIView, mediaGalleryItem: MediaGalleryItem) -} - -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 + + private let viewModel: MediaGalleryViewModel + private var hasLoadedInitialData: Bool = false + private var didFinishInitialLayout: Bool = false + private var isAutoLoadingNextPage: 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 + Storage.shared.addObserver(viewModel.pagedDataObserver) - 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() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.didFinishInitialLayout = true + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stopObservingChanges() + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + startObservingChanges(didReturnFromBackground: true) + } + + @objc func applicationDidResignActive(_ notification: Notification) { + stopObservingChanges() } - 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: - Updating + + private func performInitialScrollIfNeeded() { + // 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.didFinishInitialLayout && self.hasLoadedInitialData else { return } + + // If we have a focused item then we want to scroll to it + guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return } + + Logger.debug("scrolling to focused item at indexPath: \(focusedIndexPath)") + self.view.layoutIfNeeded() + self.collectionView.scrollToItem(at: focusedIndexPath, at: .centeredVertically, animated: false) + + // 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() } - - // 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 + + private func autoLoadNextPageIfNeeded() { + guard !self.isAutoLoadingNextPage 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 sortedVisibleIndexPaths: [IndexPath] = (self?.collectionView + .indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionHeader)) + .defaulting(to: []) + .sorted() + + for headerIndexPath in sortedVisibleIndexPaths { + 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 + } + } } } - - override public func collectionView(_ collectionView: UICollectionView, shouldDeselectItemAt indexPath: IndexPath) -> Bool { - - Logger.debug("") - - guard galleryDates.count > 0 else { - return false + + 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) } - - switch indexPath.section { - case kLoadOlderSectionIdx, loadNewerSectionIdx: - return false - default: - return true + + // 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() } } - - 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 - } + + private func stopObservingChanges() { + // Note: The 'pagedDataObserver' will continue to get changes but + // we don't want to trigger any UI updates + self.viewModel.onGalleryChange = nil } - - 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") + + 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 { + self.hasLoadedInitialData = true + self.viewModel.updateGalleryData(updatedGalleryData) + + UIView.performWithoutAnimation { + self.collectionView.reloadData() + self.performInitialScrollIfNeeded() + } return } - - guard let galleryItem = (gridCell.item as? GalleryGridCellItem)?.galleryItem else { - owsFailDebug("galleryItem was unexpectedly nil") + + // Determine if we are inserting content at the top of the collectionView + let isInsertingAtTop: Bool = { + let oldFirstSectionIsLoadMore: Bool = ( + self.viewModel.galleryData.first?.model == .loadNewer || + self.viewModel.galleryData.first?.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) + } + + 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.didFinishInitialLayout else { return } + + let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[indexPath.section] + + switch section.model { + 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 + } + } + + // 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 +633,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 + Storage.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 +714,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 +722,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 +734,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 } } } @@ -815,11 +761,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 } } @@ -894,23 +836,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/PhotoCapture.swift b/Session/Media Viewing & Editing/PhotoCapture.swift index 64d7ed650..74afdf5ea 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 { @@ -458,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 @@ -536,7 +525,6 @@ class CaptureOutput { } } -@available(iOS 10.0, *) class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput { let photoOutput = AVCapturePhotoOutput() @@ -591,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 a386e51ff..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) } @@ -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..aa79acea2 100644 --- a/Session/Media Viewing & Editing/PhotoGridViewCell.swift +++ b/Session/Media Viewing & Editing/PhotoGridViewCell.swift @@ -9,15 +9,13 @@ 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 { - - static let reuseIdentifier = "PhotoGridViewCell" - public let imageView: UIImageView private let contentTypeBadgeView: UIImageView @@ -119,28 +117,23 @@ 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 + + DispatchQueue.main.async { + 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/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index de975713e..6af3f3090 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -1,27 +1,30 @@ -// -// 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 // 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 } @@ -56,21 +59,20 @@ class SendMediaNavigationController: OWSNavigationController { // MARK: - - @objc 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 } @@ -230,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) @@ -276,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) } @@ -305,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) } @@ -441,8 +443,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) { @@ -680,3 +682,13 @@ private class DoneButton: UIView { delegate?.doneButtonWasTapped(self) } } + +// MARK: - SendMediaNavDelegate + +protocol SendMediaNavDelegate: AnyObject { + func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) + 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 new file mode 100644 index 000000000..8a3c8a8db --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift @@ -0,0 +1,244 @@ +// 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: + toVC.view.layoutIfNeeded() + toContextProvider = contextProvider + + case let navController as UINavigationController: + guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { + transitionContext.completeTransition(false) + return + } + + toVC.view.layoutIfNeeded() + 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 + toMediaContext?.mediaView.alpha = 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() + + // 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) + } + ) + } + + // 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..bd213e0b9 --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift @@ -0,0 +1,126 @@ +// 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 lastProgress: CGFloat = 0 + private var lastIncreasedProgress: CGFloat = 0 + + 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) + + // 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) + + case .cancelled: + interactiveDismissDelegate?.interactiveDismissDidFinish(self) + cancel() + + interactionInProgress = false + farEnoughToCompleteTransition = false + fastEnoughToCompleteTransition = false + lastIncreasedProgress = 0 + lastProgress = 0 + + case .ended: + if shouldCompleteTransition { + finish() + } + else { + cancel() + } + + interactiveDismissDelegate?.interactiveDismissDidFinish(self) + + 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 new file mode 100644 index 000000000..93ca9b5f9 --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaPresentationContext.swift @@ -0,0 +1,53 @@ +// 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): + // 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) + + 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..83efc9a24 --- /dev/null +++ b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift @@ -0,0 +1,207 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +class MediaZoomAnimationController: NSObject { + private let mediaItem: Media + private let shouldBounce: Bool + + init(galleryItem: MediaGalleryViewModel.Item, shouldBounce: Bool = true) { + self.mediaItem = .gallery(galleryItem) + self.shouldBounce = shouldBounce + } +} + +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 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) + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + ) + return + } + + fromMediaContext.mediaView.alpha = 0 + toMediaContext.mediaView.alpha = 0 + + toView.frame = containerView.bounds + toView.alpha = 0 + containerView.addSubview(toView) + + 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 = (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 + 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/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 779d9c158..000000000 --- a/Session/Meta/AppDelegate.m +++ /dev/null @@ -1,801 +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 - -- (OWSProfileManager *)profileManager -{ - return [OWSProfileManager sharedManager]; -} - -- (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]; - - // NOTE: Fix an edge case where user taps on the callkit notification - // but answers the call on another device - if (![self hasIncomingCallWaiting]) { - [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]; - - // 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]; - - [NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleDataNukeRequested:) name:NSNotification.dataNukeRequested object:nil]; - - OWSLogInfo(@"application: didFinishLaunchingWithOptions completed."); - - [self setUpCallHandling]; - - 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) { - 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]; - } - - 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]; - [self handleAppActivatedWithOngoingCallIfNeeded]; - } - }); - } -} - -- (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]; - [SSKEnvironment.shared.identityManager clearIdentityKey]; - [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 78d0924f6..9e79ae3cf 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -1,188 +1,642 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit import WebRTC import SessionUIKit import UIKit 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 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() + + // MARK: - Lifecycle - // MARK: Call handling - @objc func hasIncomingCallWaiting() -> Bool { + 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) + Cryptography.seedRandom() + 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.addBlock(blockObject: self) + + let mainWindow: UIWindow = UIWindow(frame: UIScreen.main.bounds) + self.loadingViewController = LoadingViewController() + + AppSetup.setupEnvironment( + appSpecificBlock: { + // Create AppEnvironment + AppEnvironment.shared.setup() + + // 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: { [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) + } + ) + + SNAppearance.switchToSessionAppearance() + + // No point continuing if we are running tests + guard !CurrentAppContext().isRunningTests else { return true } + + self.window = mainWindow + CurrentAppContext().mainWindow = mainWindow + + // Show LoadingViewController until the async database view registrations are complete. + mainWindow.rootViewController = self.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 + + NotificationCenter.default.addObserver( + self, + selector: #selector(registrationStateDidChange), + name: .registrationStateDidChange, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(showMissedCallTipsIfNeeded(_:)), + name: .missedCall, + object: nil + ) + + Logger.info("application: didFinishLaunchingWithOptions completed.") + + 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 + + // Resume database + NotificationCenter.default.post(name: Database.resumeNotification, object: self) + } + + func applicationDidEnterBackground(_ application: UIApplication) { + DDLog.flushLog() + + // 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) { + Logger.info("applicationDidReceiveMemoryWarning") + } + + func applicationWillTerminate(_ application: UIApplication) { + DDLog.flushLog() + + stopPollers() + } + + func applicationDidBecomeActive(_ application: UIApplication) { + guard !CurrentAppContext().isRunningTests else { return } + + UserDefaults.sharedLokiProject?[.isMainAppActive] = true + + ensureRootViewController() + adapt(appMode: AppModeManager.getAppModeOrSystemDefault()) + + AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in + self?.handleActivation() + + /// 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. + ClearOldTemporaryDirectories() + } + + func applicationWillResignActive(_ application: UIApplication) { + clearAllNotificationsAndRestoreBadgeCount() + + UserDefaults.sharedLokiProject?[.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) { + // Resume database + NotificationCenter.default.post(name: Database.resumeNotification, object: self) + + AppReadiness.runNowOrWhenAppDidBecomeReady { + BackgroundPoller.poll { result in + // Suspend database + NotificationCenter.default.post(name: Database.suspendNotification, object: self) + + completionHandler(result) + } + } + } + + // 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() + + /// 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) + AppReadiness.setAppIsReady() + + DeviceSleepManager.sharedInstance.removeBlock(blockObject: self) + AppVersion.sharedInstance().mainAppLaunchDidComplete() + Environment.shared?.audioSession.setup() + Environment.shared?.reachabilityManager.setup() + + 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 + + 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() + } + } + } + } + + private func showFailedMigrationAlert(error: Error?) { + let alert = UIAlertController( + title: "Session", + message: "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(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() + 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 } + + guard !Storage.isDatabasePasswordAccessible 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() + + startPollersIfNeeded() + + if CurrentAppContext().isMainApp { + syncConfigurationIfNeeded() + handleAppActivatedWithOngoingCallIfNeeded() + } + } + + private func ensureRootViewController(isPreAppReadyCall: Bool = false) { + guard (AppReadiness.isAppReady() || isPreAppReadyCall) && Storage.shared.isValid && !hasInitialRootViewController else { + return + } + + self.hasInitialRootViewController = true + self.window?.rootViewController = OWSNavigationController( + rootViewController: (Identity.userExists() ? + HomeVC() : + LandingVC() + ) + ) + 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 + + 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() + + guard CurrentAppContext().isMainApp else { return } + + CurrentAppContext().setMainAppBadgeNumber( + Storage.shared + .read { db in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let thread: TypedTableAlias = TypedTableAlias() + + 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( + // 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) + ) + } + } + + func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + AppReadiness.runNowOrWhenAppDidBecomeReady { + guard Identity.userExists() else { return } + + SessionApp.homeViewController.wrappedValue?.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() { + handleActivation() + } + + @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(shouldStartGroupPollers: Bool = true) { + guard Identity.userExists() else { return } + + poller.startIfNeeded() + + guard shouldStartGroupPollers else { return } + + ClosedGroupPoller.shared.start() + OpenGroupManager.shared.startPolling() + } + + public func stopPollers(shouldStopUserPoller: Bool = true) { + if shouldStopUserPoller { + poller.stop() + } + + ClosedGroupPoller.shared.stopAllPollers() + OpenGroupManager.shared.stopPolling() + } + + // 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) { + 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: - Call handling + + func hasIncomingCallWaiting() -> Bool { guard let call = AppEnvironment.shared.callManager.currentCall else { return false } + return !call.hasStartedConnecting } - @objc func handleAppActivatedWithOngoingCallIfNeeded() { - guard let call = AppEnvironment.shared.callManager.currentCall else { return } - guard MiniCallView.current == nil else { return } - if let callVC = CurrentAppContext().frontmostViewController() as? CallVC, callVC.call == call { return } - guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() } // FIXME: Handle more gracefully - let callVC = CallVC(for: call) - if let conversationVC = presentingVC as? ConversationVC, let contactThread = conversationVC.thread as? TSContactThread, contactThread.contactSessionID() == call.sessionID { + func handleAppActivatedWithOngoingCallIfNeeded() { + guard + let call: SessionCall = (AppEnvironment.shared.callManager.currentCall as? SessionCall), + MiniCallView.current == nil + else { 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 = CallVC(for: call) + + if let conversationVC: ConversationVC = presentingVC as? ConversationVC, conversationVC.viewModel.threadData.threadId == call.sessionId { callVC.conversationVC = conversationVC conversationVC.inputAccessoryView?.isHidden = true conversationVC.inputAccessoryView?.alpha = 0 } + 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() } - } + // MARK: - Config Sync - 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) { - let userDefaults = UserDefaults.standard - guard !userDefaults[.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 - } - - @objc 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: Configuration message - @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) + guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days + + Storage.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[.hasSyncedInitialConfiguration] { - userDefaults[.lastConfigurationSync] = Date() + // 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() } - - // MARK: Closed group poller - @objc func startClosedGroupPoller() { - guard OWSIdentityManager.shared().identityKeyPair() != nil else { return } - ClosedGroupPoller.shared.start() - } - - @objc func stopClosedGroupPoller() { - ClosedGroupPoller.shared.stop() - } - } diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index 286b2b04a..1dbb82087 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -1,20 +1,14 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import SignalUtilitiesKit -import SignalUtilitiesKit -@objc public class AppEnvironment: NSObject { +public class AppEnvironment { private static var _shared: AppEnvironment = AppEnvironment() - @objc public class var shared: AppEnvironment { - get { - return _shared - } + get { return _shared } set { guard CurrentAppContext().isRunningTests else { owsFailDebug("Can only switch environments in tests.") @@ -25,47 +19,37 @@ import SignalUtilitiesKit } } - @objc - public var accountManager: AccountManager - - @objc public var callManager: SessionCallManager - - @objc public var notificationPresenter: NotificationPresenter - - @objc public var pushRegistrationManager: PushRegistrationManager - - @objc public var fileLogger: DDFileLogger // Stored properties cannot be marked as `@available`, only classes and functions. // Instead, store a private `Any` and wrap it with a public `@available` getter private var _userNotificationActionHandler: Any? - @objc public var userNotificationActionHandler: UserNotificationActionHandler { return _userNotificationActionHandler as! UserNotificationActionHandler } - private override init() { - self.accountManager = AccountManager() + private init() { self.callManager = SessionCallManager() self.notificationPresenter = NotificationPresenter() self.pushRegistrationManager = PushRegistrationManager() self._userNotificationActionHandler = UserNotificationActionHandler() self.fileLogger = DDFileLogger() - - super.init() - + SwiftSingletons.register(self) } - @objc public func setup() { - // Hang certain singletons on SSKEnvironment too. - SSKEnvironment.shared.notificationsManager = notificationPresenter + // Hang certain singletons on Environment too. + Environment.shared?.callManager.mutate { + $0 = callManager + } + Environment.shared?.notificationsManager.mutate { + $0 = notificationPresenter + } setupLogFiles() } 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 000000000..277cd6b69 Binary files /dev/null and b/Session/Meta/Images.xcassets/x-24.imageset/x-24.png differ 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 000000000..bdadc93db Binary files /dev/null and b/Session/Meta/Images.xcassets/x-24.imageset/x-24@2x.png differ 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 000000000..cbb9bb263 Binary files /dev/null and b/Session/Meta/Images.xcassets/x-24.imageset/x-24@3x.png differ 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/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/MainAppContext.m b/Session/Meta/MainAppContext.m index 2fad58d41..6b70781cb 100644 --- a/Session/Meta/MainAppContext.m +++ b/Session/Meta/MainAppContext.m @@ -5,10 +5,7 @@ #import "MainAppContext.h" #import "Session-Swift.h" #import -#import -#import #import -#import NS_ASSUME_NONNULL_BEGIN @@ -227,9 +224,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 @@ -249,7 +245,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic - (BOOL)isRunningTests { - return getenv("runningTests_dontStartApp"); + return (NSProcessInfo.processInfo.environment[@"XCTestConfigurationFilePath"] != nil); } - (void)setNetworkActivityIndicatorVisible:(BOOL)value @@ -300,11 +296,6 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic backgroundTask = nil; } -- (id)keychainStorage -{ - return [SSKDefaultKeychainStorage shared]; -} - - (NSString *)appDocumentDirectoryPath { NSFileManager *fileManager = [NSFileManager defaultManager]; 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/SessionApp.swift b/Session/Meta/SessionApp.swift new file mode 100644 index 000000000..7b88c216c --- /dev/null +++ b/Session/Meta/SessionApp.swift @@ -0,0 +1,94 @@ +// 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 threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) { + 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 = maybeThreadInfo?.thread.variant, + let isMessageRequest: Bool = maybeThreadInfo?.isMessageRequest + else { return } + + self.presentConversation( + for: threadId, + threadVariant: variant, + isMessageRequest: isMessageRequest, + action: action, + focusInteractionId: nil, + animated: animated + ) + } + + public static func presentConversation( + for threadId: String, + threadVariant: SessionThread.Variant, + isMessageRequest: Bool, + action: ConversationViewModel.Action, + focusInteractionId: Int64?, + animated: Bool + ) { + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.presentConversation( + for: threadId, + threadVariant: threadVariant, + isMessageRequest: isMessageRequest, + action: action, + focusInteractionId: focusInteractionId, + animated: animated + ) + } + return + } + + homeViewController.wrappedValue?.show( + threadId, + variant: threadVariant, + isMessageRequest: isMessageRequest, + with: action, + focusedInteractionId: focusInteractionId, + animated: animated + ) + } + + // MARK: - Functions + + public static func resetAppData(onReset: (() -> ())? = nil) { + // This _should_ be wiped out below. + Logger.error("") + DDLog.flushLog() + + Storage.resetAllStorage() + ProfileManager.resetProfileStorage() + Attachment.resetAttachmentStorage() + 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/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index b6ca2a99c..c9b488573 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -7,28 +7,20 @@ #import // Separate iOS Frameworks from other imports. -#import "AppDelegate.h" #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" #import "OWSBezierPathView.h" #import "OWSConversationSettingsViewController.h" -#import "OWSDatabaseMigration.h" #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 @@ -38,14 +30,8 @@ #import #import #import -#import -#import #import #import -#import -#import -#import -#import #import #import #import @@ -62,21 +48,5 @@ #import #import #import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import #import #import 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 2e6e6d688..000000000 --- a/Session/Meta/SignalApp.m +++ /dev/null @@ -1,168 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "SignalApp.h" -#import "AppDelegate.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/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 3fdba3e91..d12868e54 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Speichern"; "context_menu_ban_user" = "Nutzer sperren"; "context_menu_ban_and_delete_all" = "Sperren und alles löschen"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Anhänge hinzufügen"; "accessibility_gif_button" = "GIF"; "accessibility_document_button" = "Dokument"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Fehler"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 62c842bb6..6dd37a8bb 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. */ @@ -598,6 +598,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"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 54c6aeea1..e5d4f9d44 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Guardar"; "context_menu_ban_user" = "Banear Usuario"; "context_menu_ban_and_delete_all" = "Banear y Eliminar Todo"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Añadir adjuntos Añadir archivo adjunto"; "accessibility_gif_button" = "GIF"; "accessibility_document_button" = "Documento"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Fallo"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 366aa3522..f333f1b70 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "ذخیره"; "context_menu_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"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "خطاء"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 e0821314a..b9e9b8267 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ä on 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 kuvaus on nyt ”%@”. "; /* No comment provided by engineer. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Tallenna"; "context_menu_ban_user" = "Estä Käyttäjä"; "context_menu_ban_and_delete_all" = "Estä ja Poista kaikki"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Lisää liitteitä"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Asiakirja"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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ö"; "TXT_HIDE_TITLE" = "Piilota"; "TXT_DELETE_ACCEPT" = "Hyväksy"; +"ALERT_ERROR_TITLE" = "Virhe"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Avoin ryhmä"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Yksityisviesti"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Suljettu ryhmä"; @@ -651,3 +651,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"; +"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 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"; +"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 6aa75dbd5..f99011932 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Enregistrer"; "context_menu_ban_user" = "Bannir l'utilisateur"; "context_menu_ban_and_delete_all" = "Bannir et supprimer tout"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Ajouter une pièce jointe"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Masquer"; "TXT_DELETE_ACCEPT" = "Accepter"; +"ALERT_ERROR_TITLE" = "Erreur"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Groupe public"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Message privé"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Groupe privé"; @@ -651,3 +651,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"; +"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 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"; +"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 83740d444..eca955776 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -205,11 +205,11 @@ /* 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" = " %@ समूह से हटा दिए गये हैं "; /* No comment provided by engineer. */ @@ -598,6 +598,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"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 d25b25d44..bb4556500 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Spremi"; "context_menu_ban_user" = "Zabrani korisnik"; "context_menu_ban_and_delete_all" = "Zabrani i izbriši sve"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Dodaj privitak"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Dokument"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Greška"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 cb02cfa56..6322bf9f2 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. */ @@ -598,6 +598,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"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Galat"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 759da5ac5..eedf2137b 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Salva"; "context_menu_ban_user" = "Banna utente"; "context_menu_ban_and_delete_all" = "Banna ed elimina tutto"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Aggiungi allegati"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Documento"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Nascondi"; "TXT_DELETE_ACCEPT" = "Accetta"; +"ALERT_ERROR_TITLE" = "Errore"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Gruppo Aperto"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Messaggio Privato"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Gruppo Chiuso"; @@ -651,3 +651,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"; +"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 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"; +"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 3f87389d5..253f72de8 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "保存"; "context_menu_ban_user" = "ユーザーをBAN"; "context_menu_ban_and_delete_all" = "BANしてすべてを削除する"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "添付ファイルを追加"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "ドキュメント"; @@ -605,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" = "設定"; @@ -626,7 +626,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" = "メッセージ"; @@ -638,12 +638,12 @@ "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" = "新しいリクエストがあります"; "TXT_HIDE_TITLE" = "非表示"; "TXT_DELETE_ACCEPT" = "許可"; +"ALERT_ERROR_TITLE" = "エラー"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "公開グループ"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "ダイレクトメッセージ"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "非公開グループ"; @@ -651,3 +651,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"; +"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 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"; +"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 227a50fe6..d58ebff00 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Opslaan"; "context_menu_ban_user" = "Gebruiker verbannen"; "context_menu_ban_and_delete_all" = "Blokkeer en verwijder alles"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Bijlage toevoegen"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Fout"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 2969ced80..5a08f7e35 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. */ @@ -594,6 +594,7 @@ "delete_message_for_me" = "Usuń tylko dla mnie"; "delete_message_for_everyone" = "Usuń dla wszystkich"; "delete_message_for_me_and_recipient" = "Usuń dla mnie i %@"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "context_menu_reply" = "Odpowiedz"; "context_menu_save" = "Zapisz"; "context_menu_ban_user" = "Zbanuj użytkownika"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Ukryj"; "TXT_DELETE_ACCEPT" = "Zaakceptuj"; +"ALERT_ERROR_TITLE" = "Błąd"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Otwórz grupę"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Wiadomość prywatna"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Zamknięta Grupa"; @@ -651,3 +651,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"; +"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 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"; +"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 de55cc478..a848b7587 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Salvar"; "context_menu_ban_user" = "Banir Usuário"; "context_menu_ban_and_delete_all" = "Banir e Apagar Tudo"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Adicionar anexos"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Documento"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Erro"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 dee66c734..a3984387e 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Сохранить"; "context_menu_ban_user" = "Заблокировать пользователя"; "context_menu_ban_and_delete_all" = "Забанить и удалить все"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Добавить вложения"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Документ"; @@ -605,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" = "Настройки"; @@ -626,7 +626,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" = "Сообщения"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Скрыть"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Ошибка"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 8f1ff8b34..cacf3f5d1 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" = " %@ සමූහය හැරගියා. "; /* 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. */ @@ -598,6 +598,7 @@ "context_menu_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" = "ඇමුණුම් එක්කරන්න"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "ලේඛනය"; @@ -605,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" = "සැකසුම්"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 ba3e51230..102514ede 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Uložiť"; "context_menu_ban_user" = "Zablokovanie používateľa"; "context_menu_ban_and_delete_all" = "Zabanovať a Vymazať Všetko"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Pridať Prílohy"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Dokument"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Skryť"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 f58e0edf4..da8933ad7 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. */ @@ -598,6 +598,7 @@ "context_menu_save" = "Spara"; "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"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Fel"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 a317b296c..b8f6e41ac 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. */ @@ -598,6 +598,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"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "ข้อผิดพลาด"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 431e52c30..fd93b1f63 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. */ @@ -598,6 +598,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"; @@ -605,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"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 df3267646..f936369d4 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -205,9 +205,9 @@ /* 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" = " %@ 已從群組中移除。 "; /* No comment provided by engineer. */ @@ -598,6 +598,7 @@ "context_menu_save" = "儲存"; "context_menu_ban_user" = "封鎖用戶"; "context_menu_ban_and_delete_all" = "封鎖並刪除所有"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "增加附件檔案"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "文件"; @@ -605,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" = "設定"; @@ -626,7 +626,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"; @@ -638,12 +638,12 @@ "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"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; +"ALERT_ERROR_TITLE" = "Error"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "Open Group"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "Direct Message"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "Closed Group"; @@ -651,3 +651,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"; +"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 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"; +"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 e5573bc6a..9145c3fe8 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -598,6 +598,7 @@ "context_menu_save" = "保存"; "context_menu_ban_user" = "封禁用户"; "context_menu_ban_and_delete_all" = "封禁并删除全部"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "添加附件"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "文件"; @@ -605,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" = "设置"; @@ -626,7 +626,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" = "消息"; @@ -638,12 +638,12 @@ "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" = "您有一个新的消息请求"; "TXT_HIDE_TITLE" = "隐藏"; "TXT_DELETE_ACCEPT" = "接受"; +"ALERT_ERROR_TITLE" = "错误"; "NEW_CONVERSATION_MENU_OPEN_GROUP" = "公开群组"; "NEW_CONVERSATION_MENU_DIRECT_MESSAGE" = "私信"; "NEW_CONVERSATION_MENU_CLOSED_GROUP" = "私密群组"; @@ -651,3 +651,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"; +"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 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"; +"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/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 a39574cce..96463a3f8 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.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 GRDB import PromiseKit import SessionMessagingKit import SignalUtilitiesKit @@ -94,11 +93,11 @@ 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 cancelNotification(identifier: String) + func cancelNotifications(identifiers: [String]) func clearAllNotifications() } @@ -119,20 +118,6 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { SwiftSingletons.register(self) } - // MARK: - Dependencies - - var identityManager: OWSIdentityManager { - return OWSIdentityManager.shared() - } - - var preferences: OWSPreferences { - return Environment.shared.preferences - } - - var previewType: NotificationType { - return preferences.notificationPreviewType() - } - // MARK: - @objc @@ -140,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 } } @@ -158,106 +141,104 @@ 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) { + 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.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 + // Ensure we should be showing a notification for the thread + guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest) else { + return } - 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) - - // 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 { - return - } - - let context = Contact.context(for: thread) - let senderName = Storage.shared.getContact(with: incomingMessage.authorId, using: transaction)?.displayName(for: context) ?? incomingMessage.authorId - + let messageText: String? = String.filterNotificationText(rawMessageText) let notificationTitle: String? var notificationBody: String? - let previewType = preferences.notificationPreviewType(with: transaction) + + let senderName = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant) + 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 = 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) + ) - default: - owsFailDebug("unexpected thread: \(thread)") - return + notificationTitle = (isBackgroundPoll ? groupName : + String( + format: NotificationStrings.incomingGroupMessageTitleFormat, + senderName, + groupName + ) + ) } - - default: - notificationTitle = "Session" } switch previewType { case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody - case .namePreview: notificationBody = messageText - default: notificationBody = NotificationStrings.incomingMessageBody + case .nameAndPreview: notificationBody = messageText } // If it's a message request then overwrite the body to be something generic (only show a notification // when receiving a new message request if there aren't any others or the user had hidden them) if isMessageRequest { - notificationBody = NSLocalizedString("MESSAGE_REQUESTS_NOTIFICATION", comment: "") + 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". let category = AppNotificationCategory.incomingMessage let userInfo = [ - AppNotificationUserInfoKey.threadId: threadId + 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!, threadID: thread.uniqueId!) - let sound = self.requestSound(thread: thread) + notificationBody = MentionUtilities.highlightMentions( + in: (notificationBody ?? ""), + threadVariant: thread.variant, + currentUserPublicKey: userPublicKey, + currentUserBlindedPublicKey: userBlindedKey + ) + let sound: Preferences.Sound? = self.requestSound(thread: thread) self.adaptee.notify( category: category, @@ -270,23 +251,41 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { } } - public func notifyUser(forIncomingCall callInfoMessage: TSInfoMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) { - guard !thread.isMuted else { return } - guard !thread.isGroupThread() else { return } // Calls shouldn't happen in groups - guard let threadId = thread.uniqueId else { return } - guard [ .missed, .permissionDenied ].contains(callInfoMessage.callState) else { return } // Only notify missed call + public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { + // No call notifications for muted or group threads + guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } + guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + interaction.variant == .infoCall, + let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), + let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( + CallMessage.MessageInfo.self, + from: infoMessageData + ) + else { return } + + // Only notify missed calls + guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return } let category = AppNotificationCategory.errorMessage let userInfo = [ - AppNotificationUserInfoKey.threadId: threadId + AppNotificationUserInfoKey.threadId: thread.id ] - let notificationTitle = callInfoMessage.previewText(with: transaction) + let notificationTitle = interaction.previewText(db) var notificationBody: String? - if callInfoMessage.callState == .permissionDenied { - notificationBody = String(format: "modal_call_missed_tips_explanation".localized(), thread.name(with: transaction)) + if messageInfo.state == .permissionDenied { + notificationBody = String( + format: "modal_call_missed_tips_explanation".localized(), + SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: nil, // Not supported + openGroupName: nil // Not supported + ) + ) } DispatchQueue.main.async { @@ -303,42 +302,53 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { } } - public func notifyForFailedSend(inThread thread: TSThread) { + 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: - notificationTitle = thread.name() - default: - notificationTitle = nil + case .noNameNoPreview: notificationTitle = nil + case .nameNoPreview, .nameAndPreview: + 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) + ) } 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) + let sound: Preferences.Sound? = self.requestSound(thread: thread) + + 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) } } @@ -356,27 +366,22 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { var mostRecentNotifications = TruncatedList(maxLength: kAudioNotificationsThrottleCount) - private func requestSound(thread: TSThread) -> OWSSound? { + private func requestSound(thread: SessionThread) -> Preferences.Sound? { guard checkIfShouldPlaySound() else { return nil } - return OWSSounds.notificationSound(for: thread) + return thread.notificationSound } private func checkIfShouldPlaySound() -> Bool { AssertIsOnMainThread() - guard UIApplication.shared.applicationState == .active else { - return true - } + guard UIApplication.shared.applicationState == .active else { return true } + guard Storage.shared[.playNotificationSoundInForeground] else { return false } - guard preferences.soundInForeground() else { - return false - } - - let now = NSDate.ows_millisecondTimeStamp() - let recentThreshold = now - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs)) + let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) + let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs)) let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold } @@ -384,7 +389,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return false } - mostRecentNotifications.append(now) + mostRecentNotifications.append(nowMs) return true } } @@ -395,26 +400,18 @@ class NotificationActionHandler { // MARK: - Dependencies - var signalApp: SignalApp { - return SignalApp.shared() - } - var notificationPresenter: NotificationPresenter { return AppEnvironment.shared.notificationPresenter } - var dbConnection: YapDatabaseConnection { - return OWSPrimaryStorage.shared().dbReadWriteConnection - } - // 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 = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else { throw NotificationError.failDebug("unable to find thread with id: \(threadId)") } @@ -426,27 +423,47 @@ class NotificationActionHandler { throw NotificationError.failDebug("threadId was unexpectedly nil") } - guard let thread = TSThread.fetch(uniqueId: 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)") } - - 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, seal) = Promise.pending() + + Storage.shared.writeAsync { db in + let interaction: Interaction = try Interaction( + threadId: thread.id, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: replyText, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText) + ).inserted(db) + + try Interaction.markAsRead( + db, + interactionId: interaction.id, + threadId: thread.id, + includingOlder: true, + trySendReadReceipt: true + ) + + return try MessageSender.sendNonDurably( + db, + interaction: interaction, + in: thread + ) } + .done { seal.fulfill(()) } + .catch { error in + Storage.shared.read { [weak self] db in + self?.notificationPresenter.notifyForFailedSend(db, in: thread) + } + + seal.reject(error) + } + .retainUntilComplete() + + return promise } func showThread(userInfo: [AnyHashable: Any]) throws -> Promise { @@ -457,20 +474,42 @@ 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 - signalApp.presentConversationAndScrollToFirstUnreadMessage(forThreadId: threadId, animated: shouldAnimate) + let shouldAnimate: Bool = (UIApplication.shared.applicationState == .active) + SessionApp.presentConversation(for: threadId, animated: shouldAnimate) return Promise.value(()) } func showHomeVC() -> Promise { - signalApp.showHomeView() + SessionApp.showHomeView() 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() + + Storage.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/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 1856caa58..1cd8d71cf 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -241,21 +241,53 @@ 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 - } - 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 - if let error = error { - SNLog("[Calls] Failed to report incoming call to CallKit due to error: \(error)") - } + + 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 + } + + 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/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 1377451ff..ca26e0347 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -1,107 +1,175 @@ -// -// 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: Int = -1 + public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + + public static func run( + _ job: Job, + queue: DispatchQueue, + 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 } - - 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 + + // 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, queue: queue, success: success, failure: failure, deferred: deferred) + } + return } - - if (voipToken != self.preferences.getVoipToken()) { - Logger.info("Recording new voip token") - self.preferences.setVoipToken(voipToken) - didTokensChange = true + + // 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 } + + 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) + 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(on: queue) { (pushToken: String, voipToken: String) -> Promise in + let lastPushToken: String? = Storage.shared[.lastRecordedPushToken] + let lastVoipToken: String? = Storage.shared[.lastRecordedVoipToken] + let shouldUploadTokens: Bool = ( + !uploadOnlyIfStale || ( + lastPushToken != pushToken || + lastVoipToken != voipToken + ) || + lastAppVersion != currentAppVersion + ) - if (didTokensChange) { - NotificationCenter.default.postNotificationNameAsync(SyncPushTokensJob.PushTokensDidChange, object: nil) - } + guard shouldUploadTokens else { return Promise.value(()) } + + let (promise, seal) = Promise.pending() + + SyncPushTokensJob.registerForPushNotifications( + pushToken: pushToken, + voipToken: voipToken, + isForcedUpdate: shouldUploadTokens, + success: { seal.fulfill(()) }, + failure: seal.reject + ) + + return promise + .done(on: queue) { _ in + Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") + + Storage.shared.write { db in + db[.lastRecordedPushToken] = pushToken + db[.lastRecordedVoipToken] = voipToken + } + } + } + .ensure(on: queue) { 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, + queue: DispatchQueue.global(qos: .default), + 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)) ]" } + +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) + } + .retainUntilComplete() + } +} + +// 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..c7fd88113 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) } @@ -139,21 +140,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) { @@ -191,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) } } @@ -229,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 { @@ -246,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/Onboarding/DisplayNameVC.swift b/Session/Onboarding/DisplayNameVC.swift index 5e2940e3c..d5ff17982 100644 --- a/Session/Onboarding/DisplayNameVC.swift +++ b/Session/Onboarding/DisplayNameVC.swift @@ -1,24 +1,34 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class DisplayNameVC : BaseVC { +import UIKit +import SessionUIKit +import SessionMessagingKit + +final class DisplayNameVC: BaseVC { private var spacer1HeightConstraint: NSLayoutConstraint! private var spacer2HeightConstraint: NSLayoutConstraint! private var registerButtonBottomOffsetConstraint: NSLayoutConstraint! private var bottomConstraint: NSLayoutConstraint! - // MARK: Components + // MARK: - Components + private lazy var displayNameTextField: TextField = { let result = TextField(placeholder: NSLocalizedString("vc_display_name_text_field_hint", comment: "")) result.layer.borderColor = Colors.text.cgColor result.accessibilityLabel = "Display name text field" + return result }() - // MARK: Lifecycle + // MARK: - Lifecycle + override func viewDidLoad() { super.viewDidLoad() + setUpGradientBackground() setUpNavBarStyle() setUpNavBarSessionIcon() + // Set up title label let titleLabel = UILabel() titleLabel.textColor = Colors.text @@ -26,6 +36,7 @@ final class DisplayNameVC : BaseVC { titleLabel.text = NSLocalizedString("vc_display_name_title_2", comment: "") titleLabel.numberOfLines = 0 titleLabel.lineBreakMode = .byWordWrapping + // Set up explanation label let explanationLabel = UILabel() explanationLabel.textColor = Colors.text @@ -33,6 +44,7 @@ final class DisplayNameVC : BaseVC { explanationLabel.text = NSLocalizedString("vc_display_name_explanation", comment: "") explanationLabel.numberOfLines = 0 explanationLabel.lineBreakMode = .byWordWrapping + // Set up spacers let topSpacer = UIView.vStretchingSpacer() let spacer1 = UIView() @@ -42,27 +54,32 @@ final class DisplayNameVC : BaseVC { let bottomSpacer = UIView.vStretchingSpacer() let registerButtonBottomOffsetSpacer = UIView() registerButtonBottomOffsetConstraint = registerButtonBottomOffsetSpacer.set(.height, to: Values.onboardingButtonBottomOffset) + // Set up register button let registerButton = Button(style: .prominentFilled, size: .large) registerButton.setTitle(NSLocalizedString("continue_2", comment: ""), for: UIControl.State.normal) registerButton.titleLabel!.font = .boldSystemFont(ofSize: Values.mediumFontSize) registerButton.addTarget(self, action: #selector(register), for: UIControl.Event.touchUpInside) + // Set up register button container let registerButtonContainer = UIView() registerButtonContainer.addSubview(registerButton) if UIDevice.current.isIPad { registerButton.set(.width, to: Values.iPadButtonWidth) registerButton.center(in: registerButtonContainer) - } else { + } + else { registerButton.pin(.leading, to: .leading, of: registerButtonContainer, withInset: Values.massiveSpacing) registerButtonContainer.pin(.trailing, to: .trailing, of: registerButton, withInset: Values.massiveSpacing) } registerButton.pin(.top, to: .top, of: registerButtonContainer) registerButtonContainer.pin(.bottom, to: .bottom, of: registerButton) + // Set up top stack view let topStackView = UIStackView(arrangedSubviews: [ titleLabel, spacer1, explanationLabel, spacer2, displayNameTextField ]) topStackView.axis = .vertical topStackView.alignment = .fill + // Set up top stack view container let topStackViewContainer = UIView() topStackViewContainer.addSubview(topStackView) @@ -70,6 +87,7 @@ final class DisplayNameVC : BaseVC { topStackView.pin(.top, to: .top, of: topStackViewContainer) topStackViewContainer.pin(.trailing, to: .trailing, of: topStackView, withInset: Values.veryLargeSpacing) topStackViewContainer.pin(.bottom, to: .bottom, of: topStackView) + // Set up main stack view let mainStackView = UIStackView(arrangedSubviews: [ topSpacer, topStackViewContainer, bottomSpacer, registerButtonContainer, registerButtonBottomOffsetSpacer ]) mainStackView.axis = .vertical @@ -80,9 +98,11 @@ final class DisplayNameVC : BaseVC { mainStackView.pin(.trailing, to: .trailing, of: view) bottomConstraint = mainStackView.pin(.bottom, to: .bottom, of: view) topSpacer.heightAnchor.constraint(equalTo: bottomSpacer.heightAnchor, multiplier: 1).isActive = true + // Dismiss keyboard on tap let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) view.addGestureRecognizer(tapGestureRecognizer) + // Listen to keyboard notifications let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) @@ -98,18 +118,22 @@ final class DisplayNameVC : BaseVC { NotificationCenter.default.removeObserver(self) } - // MARK: General + // MARK: - General + @objc private func dismissKeyboard() { displayNameTextField.resignFirstResponder() } - // MARK: Updating + // MARK: - Updating + @objc private func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return } + bottomConstraint.constant = -newHeight // Negative due to how the constraint is set up registerButtonBottomOffsetConstraint.constant = isIPhone5OrSmaller ? Values.smallSpacing : Values.largeSpacing spacer1HeightConstraint.constant = isIPhone5OrSmaller ? Values.smallSpacing : Values.mediumSpacing spacer2HeightConstraint.constant = isIPhone5OrSmaller ? Values.smallSpacing : Values.mediumSpacing + UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() } @@ -120,12 +144,14 @@ final class DisplayNameVC : BaseVC { registerButtonBottomOffsetConstraint.constant = Values.onboardingButtonBottomOffset spacer1HeightConstraint.constant = isIPhone5OrSmaller ? Values.smallSpacing : Values.veryLargeSpacing spacer2HeightConstraint.constant = isIPhone5OrSmaller ? Values.smallSpacing : Values.veryLargeSpacing + UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() } } - // MARK: Interaction + // MARK: - Interaction + @objc private func register() { func showError(title: String, message: String = "") { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) @@ -136,11 +162,19 @@ 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( + queue: DispatchQueue.global(qos: .default), + profileName: displayName, + image: nil, + imageFilePath: nil, + requiredSync: false + ) let pnModeVC = PNModeVC() - navigationController!.pushViewController(pnModeVC, animated: true) + navigationController?.pushViewController(pnModeVC, animated: true) } } diff --git a/Session/Onboarding/LandingVC.swift b/Session/Onboarding/LandingVC.swift index 44b15db4d..2a34fff7d 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 f8d1fb514..5791ee005 100644 --- a/Session/Onboarding/LinkDeviceVC.swift +++ b/Session/Onboarding/LinkDeviceVC.swift @@ -1,4 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import PromiseKit +import SessionUtilitiesKit +import SessionSnodeKit final class LinkDeviceVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) @@ -123,16 +128,25 @@ 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() })) 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() + + Identity.didRegister() + + // Now that we have registered get the Snode pool + GetSnodePoolJob.run() + NotificationCenter.default.addObserver(self, selector: #selector(handleInitialConfigurationMessageReceived), name: .initialConfigurationMessageReceived, object: nil) ModalActivityIndicatorViewController.present(fromViewController: navigationController!) { [weak self] modal in self?.activityIndicatorModal = modal @@ -140,7 +154,6 @@ final class LinkDeviceVC : BaseVC, UIPageViewControllerDataSource, UIPageViewCon } @objc private func handleInitialConfigurationMessageReceived(_ notification: Notification) { - TSAccountManager.sharedInstance().phoneNumberAwaitingVerification = OWSIdentityManager.shared().identityKeyPair()!.hexEncodedPublicKey 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..3814a223c 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -1,4 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import Sodium +import GRDB +import Curve25519Kit +import SessionUtilitiesKit +import SessionMessagingKit enum Onboarding { @@ -7,34 +14,42 @@ 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 - let user = Contact(sessionID: x25519PublicKey) - user.isApproved = true - user.didApproveMe = true - Storage.shared.setContact(user, using: transaction) + + Storage.shared.write { db in + try Contact(id: x25519PublicKey) + .with( + isApproved: true, + didApproveMe: true + ) + .save(db) } + 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: + 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 + // 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 + Storage.shared.write { db in db[.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/PNModeVC.swift b/Session/Onboarding/PNModeVC.swift index d8684976d..37c4eeb3d 100644 --- a/Session/Onboarding/PNModeVC.swift +++ b/Session/Onboarding/PNModeVC.swift @@ -1,4 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import PromiseKit +import SessionMessagingKit +import SessionSnodeKit final class PNModeVC : BaseVC, OptionViewDelegate { @@ -94,11 +99,15 @@ final class PNModeVC : BaseVC, OptionViewDelegate { return presentAlert(alert) } UserDefaults.standard[.isUsingFullAPNs] = (selectedOptionView == apnsOptionView) - 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() + + Identity.didRegister() + + // Go to the home screen + let homeVC: HomeVC = HomeVC() + self.navigationController?.setViewControllers([ homeVC ], animated: true) + + // Now that we have registered get the Snode pool and sync push tokens + GetSnodePoolJob.run() + SyncPushTokensJob.run(uploadOnlyIfStale: false) } } diff --git a/Session/Onboarding/RegisterVC.swift b/Session/Onboarding/RegisterVC.swift index 65c5fa39a..fde5a52a4 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() } } @@ -141,7 +145,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 35ed24610..fbfa4e3ea 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! @@ -9,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 @@ -159,7 +164,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/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 a80fed60d..23bbc3b1f 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.fetchUserPrivateKey()!.toHexString()) }() private lazy var redactedMnemonic: String = { @@ -168,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) + + 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 76957868d..10de3d156 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -1,72 +1,88 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class JoinOpenGroupVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSQRScannerDelegate { +import UIKit +import GRDB +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit + +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.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 + // 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 = UIDevice.current.isIPad ? navigationBar.height() + 20 : navigationBar.height() - } else { - tabBarInset = 0 - } - tabBar.pin(.top, to: .top, of: view, withInset: tabBarInset) + tabBar.pin( + .top, + to: .top, + of: view, + withInset: (UIDevice.current.isIPad ? navBarHeight + 20 : navBarHeight) + ) view.pin(.trailing, to: .trailing, of: tabBar) + // Page VC constraints let pageVCView = pageVC.view! view.addSubview(pageVCView) @@ -74,77 +90,94 @@ 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) { + + fileprivate func joinOpenGroup(with urlString: 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) = OpenGroupManager.parseOpenGroup(from: urlString) else { + showError( + title: "invalid_url".localized(), + message: "Please check the URL you entered and try again." + ) + return } + + joinOpenGroup(roomToken: room, server: server, publicKey: publicKey) } - - fileprivate func joinV2OpenGroup(room: String, server: String, publicKey: String) { - guard !isJoining else { return } + + fileprivate func joinOpenGroup(roomToken: String, server: String, publicKey: String) { + 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 + Storage.shared + .writeAsync { db in + OpenGroupManager.shared.add( + db, + roomToken: roomToken, + server: server, + publicKey: publicKey, + isConfigMessage: false + ) + } .done(on: DispatchQueue.main) { [weak self] _ in + Storage.shared.writeAsync { db in + try 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 @@ -153,59 +186,74 @@ 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? + private var isKeyboardShowing = false private var bottomConstraint: NSLayoutConstraint! - private let bottomMargin: CGFloat = UIDevice.current.isIPad ? Values.largeSpacing : 0 + private let bottomMargin: CGFloat = (UIDevice.current.isIPad ? Values.largeSpacing : 0) + + // MARK: - UI - // MARK: Components private lazy var urlTextView: TextView = { - let result = TextView(placeholder: NSLocalizedString("vc_enter_chat_url_text_field_hint", comment: "")) + let result: TextView = TextView(placeholder: "vc_enter_chat_url_text_field_hint".localized()) result.keyboardType = .URL result.autocapitalizationType = .none result.autocorrectionType = .no - return result - }() - - private lazy var suggestionGrid: OpenGroupSuggestionGrid = { - let maxWidth = UIScreen.main.bounds.width - Values.largeSpacing * 2 - let result = OpenGroupSuggestionGrid(maxWidth: maxWidth) - result.delegate = self + return result }() private lazy var suggestionGridTitleLabel: UILabel = { - let result = UILabel() + let result: UILabel = UILabel() result.textColor = Colors.text result.font = .boldSystemFont(ofSize: Values.largeFontSize) - result.text = NSLocalizedString("vc_join_open_group_suggestions_title", comment: "") + result.text = "vc_join_open_group_suggestions_title".localized() result.numberOfLines = 0 result.lineBreakMode = .byWordWrapping + result.setContentHuggingPriority(.required, for: .vertical) + return result }() + + private lazy var suggestionGrid: OpenGroupSuggestionGrid = { + 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 + // 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(wrapping: nextButton, withInsets: UIEdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80), shouldAdaptForIPadWithWidth: Values.iPadButtonWidth) + + let nextButtonContainer = UIView( + wrapping: nextButton, + withInsets: UIEdgeInsets(top: 0, leading: 80, bottom: 0, trailing: 80), + shouldAdaptForIPadWithWidth: Values.iPadButtonWidth + ) + // Stack view let stackView = UIStackView(arrangedSubviews: [ urlTextView, UIView.spacer(withHeight: Values.mediumSpacing), suggestionGridTitleLabel, UIView.spacer(withHeight: Values.mediumSpacing), suggestionGrid, UIView.vStretchingSpacer(), nextButtonContainer ]) stackView.axis = .vertical @@ -213,86 +261,123 @@ private final class EnterURLVC : UIViewController, UIGestureRecognizerDelegate, stackView.layoutMargins = UIEdgeInsets(uniform: Values.largeSpacing) stackView.isLayoutMarginsRelativeArrangement = true view.addSubview(stackView) + stackView.pin(.leading, to: .leading, of: view) stackView.pin(.top, to: .top, of: view) view.pin(.trailing, to: .trailing, of: stackView) + bottomConstraint = view.pin(.bottom, to: .bottom, of: stackView, withInset: bottomMargin) + // 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) + // Listen to keyboard 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.default.addObserver( + self, + selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleKeyboardWillHideNotification(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) } - deinit { NotificationCenter.default.removeObserver(self) } - // 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) + + return ( + (!suggestionGrid.isHidden && !suggestionGrid.frame.contains(location)) || + (suggestionGrid.isHidden && location.y > urlTextView.frame.maxY) + ) } - func join(_ room: OpenGroupAPIV2.Info) { - joinOpenGroupVC.joinV2OpenGroup(room: room.id, server: OpenGroupAPIV2.defaultServer, publicKey: OpenGroupAPIV2.defaultServerPublicKey) + func join(_ room: OpenGroupAPI.Room) { + joinOpenGroupVC?.joinOpenGroup( + roomToken: room.token, + server: OpenGroupAPI.defaultServer, + publicKey: OpenGroupAPI.defaultServerPublicKey + ) } - + @objc private func joinOpenGroup() { let url = urlTextView.text?.trimmingCharacters(in: .whitespaces) ?? "" - joinOpenGroupVC.joinOpenGroup(with: url) + joinOpenGroupVC?.joinOpenGroup(with: url) } - // MARK: Updating + // MARK: - Updating + @objc private func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { guard !isKeyboardShowing else { return } isKeyboardShowing = true - guard let newHeight = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.size.height else { return } - bottomConstraint.constant = newHeight + bottomMargin - UIView.animate(withDuration: 0.25, animations: { - self.view.layoutIfNeeded() - self.suggestionGridTitleLabel.alpha = 0 - self.suggestionGrid.alpha = 0 - }, completion: { _ in - self.suggestionGridTitleLabel.isHidden = true - self.suggestionGrid.isHidden = true - }) + + guard let endFrame: CGRect = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return } + guard endFrame.minY < UIScreen.main.bounds.height else { return } + + bottomConstraint.constant = endFrame.size.height + bottomMargin + + UIView.animate( + withDuration: 0.25, + animations: { [weak self] in + self?.view.layoutIfNeeded() + self?.suggestionGridTitleLabel.alpha = 0 + self?.suggestionGrid.alpha = 0 + }, + completion: { [weak self] _ in + self?.suggestionGridTitleLabel.isHidden = true + self?.suggestionGrid.isHidden = true + } + ) } @objc private func handleKeyboardWillHideNotification(_ notification: Notification) { guard isKeyboardShowing else { return } + isKeyboardShowing = false bottomConstraint.constant = bottomMargin - UIView.animate(withDuration: 0.25) { - self.view.layoutIfNeeded() - self.suggestionGridTitleLabel.isHidden = false - self.suggestionGridTitleLabel.alpha = 1 - self.suggestionGrid.isHidden = false - self.suggestionGrid.alpha = 1 + + UIView.animate(withDuration: 0.25) { [weak self] in + self?.view.layoutIfNeeded() + self?.suggestionGridTitleLabel.isHidden = false + self?.suggestionGridTitleLabel.alpha = 1 + self?.suggestionGrid.isHidden = false + self?.suggestionGrid.alpha = 1 } } } -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 @@ -301,34 +386,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/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 2d3094718..ee9e83ed7 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -1,14 +1,19 @@ import PromiseKit import NVActivityIndicatorView +import SessionMessagingKit import SessionUIKit -final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { +final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private let maxWidth: CGFloat - private var rooms: [OpenGroupAPIV2.Info] = [] { didSet { update() } } + private var rooms: [OpenGroupAPI.Room] = [] { didSet { update() } } private var heightConstraint: NSLayoutConstraint! var delegate: OpenGroupSuggestionGridDelegate? - // MARK: UI Components + // MARK: - UI + + private static let cellHeight: CGFloat = 40 + private static let separatorWidth = 1 / UIScreen.main.scale + private lazy var layout: UICollectionViewFlowLayout = { let result = UICollectionViewFlowLayout() result.minimumLineSpacing = 0 @@ -32,7 +37,7 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl result.set(.height, to: OpenGroupSuggestionGrid.cellHeight) return result }() - + private lazy var errorView: UIView = { let result: UIView = UIView() result.isHidden = true @@ -69,11 +74,8 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl return result }() - // 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) @@ -118,7 +120,7 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight) widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true - OpenGroupAPIV2.getDefaultRoomsIfNeeded() + OpenGroupManager.getDefaultRoomsIfNeeded() .done { [weak self] rooms in self?.rooms = rooms } @@ -127,7 +129,8 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl } } - // MARK: Updating + // MARK: - Updating + private func update() { spinner.stopAnimating() spinner.isHidden = true @@ -138,13 +141,15 @@ final class OpenGroupSuggestionGrid : UIView, UICollectionViewDataSource, UIColl errorView.isHidden = (roomCount > 0) } - // MARK: Layout + // MARK: - Layout + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let cellWidth = UIDevice.current.isIPad ? maxWidth / 4 : maxWidth / 2 return CGSize(width: cellWidth, 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) } @@ -155,18 +160,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: OpenGroupAPIV2.Info? { didSet { update() } } + var room: OpenGroupAPI.Room? { didSet { update() } } static let identifier = "OpenGroupSuggestionGridCell" @@ -233,8 +240,19 @@ extension OpenGroupSuggestionGrid { } private func update() { - guard let room = room else { return } - let promise = OpenGroupAPIV2.getGroupImage(for: room.id, on: OpenGroupAPIV2.defaultServer) + guard let room: OpenGroupAPI.Room = room else { return } + + label.text = room.name + + // Only continue if we have a room image + guard let imageId: String = room.imageId else { + imageView.isHidden = true + return + } + + let promise = Storage.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) @@ -250,14 +268,12 @@ extension OpenGroupSuggestionGrid { } } } - - label.text = room.name } } } -// MARK: Delegate +// MARK: - Delegate + protocol OpenGroupSuggestionGridDelegate { - - func join(_ room: OpenGroupAPIV2.Info) + func join(_ room: OpenGroupAPI.Room) } 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 26e7f9392..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 { @@ -103,26 +104,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 } } diff --git a/Session/Settings/ChatSettingsViewController.swift b/Session/Settings/ChatSettingsViewController.swift new file mode 100644 index 000000000..9ff0c80e3 --- /dev/null +++ b/Session/Settings/ChatSettingsViewController.swift @@ -0,0 +1,62 @@ +// 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) + } + + 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: { Storage.shared[.trimOpenGroupMessagesOlderThanSixMonths] }, + target: self, + selector: #selector(didToggleTrimOpenGroupsSwitch(_:)) + )) + updatedContents.addSection(messageTrimming) + + self.contents = updatedContents + } + + // MARK: - Actions + + @objc private func didToggleTrimOpenGroupsSwitch(_ sender: UISwitch) { + let switchIsOn: Bool = sender.isOn + + Storage.shared.writeAsync( + updates: { db in + db[.trimOpenGroupMessagesOlderThanSixMonths] = !switchIsOn + }, + completion: { [weak self] _, _ in + self?.updateTableContents() + } + ) + } + + @objc private func close(_ sender: UIBarButtonItem) { + self.navigationController?.dismiss(animated: true) + } +} diff --git a/Session/Settings/NotificationSettingsOptionsViewController.m b/Session/Settings/NotificationSettingsOptionsViewController.m index ebb120b40..e772ec061 100644 --- a/Session/Settings/NotificationSettingsOptionsViewController.m +++ b/Session/Settings/NotificationSettingsOptionsViewController.m @@ -4,8 +4,6 @@ #import "NotificationSettingsOptionsViewController.h" #import "Session-Swift.h" -#import "SignalApp.h" -#import #import @implementation NotificationSettingsOptionsViewController @@ -31,27 +29,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]; @@ -59,11 +53,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 b884aa121..17825bef4 100644 --- a/Session/Settings/NotificationSettingsViewController.m +++ b/Session/Settings/NotificationSettingsViewController.m @@ -7,9 +7,7 @@ #import "NotificationSettingsViewController.h" #import "NotificationSettingsOptionsViewController.h" #import "OWSSoundSettingsViewController.h" -#import -#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", @"") @@ -66,7 +62,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]; @@ -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; @@ -93,7 +89,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 = @@ -111,15 +107,13 @@ - (void)didToggleSoundNotificationsSwitch:(UISwitch *)sender { - [Environment.shared.preferences setSoundInForeground:sender.on]; + [SMKPreferences setPlayNotificationSoundInForeground:sender.on]; } - (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 8d495c641..4877e0e90 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -1,20 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit import SessionUIKit import SessionSnodeKit import SessionMessagingKit +import SignalUtilitiesKit @objc(LKNukeDataModal) -final class NukeDataModal : Modal { +final class NukeDataModal: Modal { + + // MARK: - Components - // MARK: Components private lazy var titleLabel: UILabel = { 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 + return result }() @@ -22,10 +27,11 @@ 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 + return result }() @@ -38,8 +44,9 @@ 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 }() @@ -48,6 +55,7 @@ final class NukeDataModal : Modal { result.axis = .horizontal result.spacing = Values.mediumSpacing result.distribution = .fillEqually + return result }() @@ -58,8 +66,9 @@ 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 }() @@ -72,8 +81,9 @@ 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 }() @@ -83,6 +93,7 @@ final class NukeDataModal : Modal { result.spacing = Values.mediumSpacing result.distribution = .fillEqually result.alpha = 0 + return result }() @@ -92,6 +103,7 @@ final class NukeDataModal : Modal { buttonStackView2.pin(to: result) result.addSubview(buttonStackView1) buttonStackView1.pin(to: result) + return result }() @@ -99,6 +111,7 @@ final class NukeDataModal : Modal { let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel ]) result.axis = .vertical result.spacing = Values.largeSpacing + return result }() @@ -106,10 +119,12 @@ final class NukeDataModal : Modal { let result = UIStackView(arrangedSubviews: [ contentStackView, buttonStackViewContainer ]) result.axis = .vertical result.spacing = Values.largeSpacing - Values.smallFontSize / 2 + return result }() - // MARK: Lifecycle + // MARK: - Lifecycle + override func populateContentView() { contentView.addSubview(mainStackView) mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.largeSpacing) @@ -118,54 +133,107 @@ final class NukeDataModal : Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: mainStackView.spacing) } - // MARK: Interaction + // MARK: - Interaction + @objc private func clearAllData() { UIView.animate(withDuration: 0.25) { self.buttonStackView1.alpha = 0 self.buttonStackView2.alpha = 1 } - UIView.transition(with: explanationLabel, duration: 0.25, options: .transitionCrossDissolve, animations: { - self.explanationLabel.text = NSLocalizedString("modal_clear_all_data_explanation_2", comment: "") - }, completion: nil) + + UIView.transition( + with: explanationLabel, + duration: 0.25, + options: .transitionCrossDissolve, + animations: { + self.explanationLabel.text = "modal_clear_all_data_explanation_2".localized() + }, + completion: nil + ) } @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() + Storage.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 + } + .retainUntilComplete() } } @objc private func clearEntireAccount() { - ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in - SnodeAPI.clearAllData().done(on: DispatchQueue.main) { confirmations in - 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 - UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later - NotificationCenter.default.post(name: .dataNukeRequested, object: nil) - } else { - let message: String - if potentiallyMaliciousSnodes.count == 1 { - message = String(format: NSLocalizedString("dialog_clear_all_data_deletion_failed_1", comment: ""), potentiallyMaliciousSnodes[0]) - } else { - message = String(format: NSLocalizedString("dialog_clear_all_data_deletion_failed_2", comment: ""), String(potentiallyMaliciousSnodes.count), potentiallyMaliciousSnodes.joined(separator: ", ")) + ModalActivityIndicatorViewController + .present(fromViewController: self, canCancel: false) { [weak self] _ in + SnodeAPI.clearAllData() + .done(on: DispatchQueue.main) { confirmations in + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + + let potentiallyMaliciousSnodes = confirmations.compactMap { $0.value == false ? $0.key : nil } + + if potentiallyMaliciousSnodes.isEmpty { + self?.deleteAllLocalData() + } + else { + let message: String + if potentiallyMaliciousSnodes.count == 1 { + message = String(format: "dialog_clear_all_data_deletion_failed_1".localized(), potentiallyMaliciousSnodes[0]) + } + else { + message = String(format: "dialog_clear_all_data_deletion_failed_2".localized(), String(potentiallyMaliciousSnodes.count), potentiallyMaliciousSnodes.joined(separator: ", ")) + } + + let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) + + self?.presentAlert(alert) + } + } + .catch(on: DispatchQueue.main) { error in + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + + let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) + self?.presentAlert(alert) } - let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) - self?.presentAlert(alert) - } - }.catch(on: DispatchQueue.main) { error in - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - let alert = UIAlertController(title: "Error", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) - self?.presentAlert(alert) } + } + + 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 the app badge and notifications + AppEnvironment.shared.notificationPresenter.clearAllNotifications() + CurrentAppContext().setMainAppBadgeNumber(0) + + // 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/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/Settings/PrivacySettingsTableViewController.m b/Session/Settings/PrivacySettingsTableViewController.m index b07fbbf7f..de52c3f75 100644 --- a/Session/Settings/PrivacySettingsTableViewController.m +++ b/Session/Settings/PrivacySettingsTableViewController.m @@ -6,14 +6,11 @@ #import "Session-Swift.h" #import -#import -#import #import #import #import -#import -#import +#import #import NS_ASSUME_NONNULL_BEGIN @@ -62,23 +59,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 @@ -97,7 +77,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 [SMKPreferences areReadReceiptsEnabled]; } isEnabledBlock:^{ return YES; @@ -115,7 +95,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 [SMKPreferences areTypingIndicatorsEnabled]; } isEnabledBlock:^{ return YES; @@ -168,7 +148,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; @@ -193,7 +173,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; @@ -212,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; @@ -255,35 +235,35 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s - (void)deleteThreadsAndMessages { - [ThreadUtil deleteAllContent]; + [SMKThread deleteAll]; } - (void)didToggleScreenSecuritySwitch:(UISwitch *)sender { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled screen security: %@", enabled ? @"ON" : @"OFF"); - [self.preferences setScreenSecurity:enabled]; + [SMKPreferences setScreenSecurity:enabled]; } - (void)didToggleReadReceiptsSwitch:(UISwitch *)sender { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled areReadReceiptsEnabled: %@", enabled ? @"ON" : @"OFF"); - [self.readReceiptManager setAreReadReceiptsEnabled:enabled]; + [SMKPreferences setAreReadReceiptsEnabled:enabled]; } - (void)didToggleTypingIndicatorsSwitch:(UISwitch *)sender { BOOL enabled = sender.isOn; OWSLogInfo(@"toggled areTypingIndicatorsEnabled: %@", enabled ? @"ON" : @"OFF"); - [self.typingIndicators setTypingIndicatorsEnabledWithValue: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)didToggleCallsEnabled:(UISwitch *)sender @@ -297,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/Settings/QRCodeVC.swift b/Session/Settings/QRCodeVC.swift index 80aaf80f7..be39795b0 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) @@ -66,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) @@ -122,13 +121,22 @@ 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) - } else { - let thread = TSContactThread.getOrCreateThread(contactSessionID: hexEncodedPublicKey) + } + else { + let maybeThread: SessionThread? = Storage.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/Settings/SeedModal.swift b/Session/Settings/SeedModal.swift index 46612e81b..0ac3c36c0 100644 --- a/Session/Settings/SeedModal.swift +++ b/Session/Settings/SeedModal.swift @@ -1,18 +1,22 @@ +// 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.fetchUserPrivateKey()!.toHexString()) }() - // MARK: Lifecycle + // MARK: - Lifecycle + override func populateContentView() { // Set up title label let titleLabel = UILabel() @@ -22,6 +26,7 @@ final class SeedModal : Modal { titleLabel.numberOfLines = 0 titleLabel.lineBreakMode = .byWordWrapping titleLabel.textAlignment = .center + // Set up mnemonic label let mnemonicLabel = UILabel() mnemonicLabel.textColor = Colors.text @@ -30,6 +35,7 @@ final class SeedModal : Modal { mnemonicLabel.numberOfLines = 0 mnemonicLabel.lineBreakMode = .byWordWrapping mnemonicLabel.textAlignment = .center + // Set up mnemonic label container let mnemonicLabelContainer = UIView() mnemonicLabelContainer.addSubview(mnemonicLabel) @@ -37,6 +43,7 @@ final class SeedModal : Modal { mnemonicLabelContainer.layer.cornerRadius = TextField.cornerRadius mnemonicLabelContainer.layer.borderWidth = 1 mnemonicLabelContainer.layer.borderColor = Colors.text.cgColor + // Set up explanation label let explanationLabel = UILabel() explanationLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) @@ -45,6 +52,7 @@ final class SeedModal : Modal { explanationLabel.numberOfLines = 0 explanationLabel.lineBreakMode = .byWordWrapping explanationLabel.textAlignment = .center + // Set up copy button let copyButton = UIButton() copyButton.set(.height, to: Values.mediumButtonHeight) @@ -54,15 +62,18 @@ final class SeedModal : Modal { copyButton.setTitleColor(Colors.text, for: UIControl.State.normal) copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal) copyButton.addTarget(self, action: #selector(copySeed), for: UIControl.Event.touchUpInside) + // Set up button stack view let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, copyButton ]) buttonStackView.axis = .horizontal buttonStackView.spacing = Values.mediumSpacing buttonStackView.distribution = .fillEqually + // Content stack view let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, mnemonicLabelContainer, explanationLabel ]) contentStackView.axis = .vertical contentStackView.spacing = Values.largeSpacing + // Set up stack view let spacing = Values.largeSpacing - Values.smallFontSize / 2 let stackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) @@ -73,12 +84,13 @@ 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: spacing) + // Mark seed as viewed - UserDefaults.standard[.hasViewedSeed] = true - NotificationCenter.default.post(name: .seedViewed, object: nil) + Storage.shared.writeAsync { db in db[.hasViewedSeed] = true } } - // MARK: Interaction + // MARK: - Interaction + @objc private func copySeed() { UIPasteboard.general.string = mnemonic dismiss(animated: true, completion: nil) diff --git a/Session/Settings/SettingsVC.swift b/Session/Settings/SettingsVC.swift index e3b62e821..27d4ecec0 100644 --- a/Session/Settings/SettingsVC.swift +++ b/Session/Settings/SettingsVC.swift @@ -1,12 +1,17 @@ -import UIKit -import SessionMessagingKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class SettingsVC : BaseVC, AvatarViewHelperDelegate { - private var profilePictureToBeUploaded: UIImage? +import UIKit +import SessionUIKit +import SessionUtilitiesKit +import SessionMessagingKit +import SignalUtilitiesKit + +final class SettingsVC: BaseVC, AvatarViewHelperDelegate { private var displayNameToBeUploaded: String? private var isEditingDisplayName = false { didSet { handleIsEditingDisplayNameChanged() } } - // MARK: Components + // MARK: - Components + private lazy var profilePictureView: ProfilePictureView = { let result = ProfilePictureView() let size = Values.largeProfilePictureSize @@ -15,12 +20,14 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { result.set(.height, to: size) result.accessibilityLabel = "Edit profile picture button" result.isAccessibilityElement = true + return result }() private lazy var profilePictureUtilities: AvatarViewHelper = { let result = AvatarViewHelper() result.delegate = self + return result }() @@ -30,6 +37,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize) result.lineBreakMode = .byTruncatingTail result.textAlignment = .center + return result }() @@ -37,6 +45,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { let result = TextField(placeholder: NSLocalizedString("vc_settings_display_name_text_field_hint", comment: ""), usesDefaultHeight: false) result.textAlignment = .center result.accessibilityLabel = "Edit display name text field" + return result }() @@ -48,6 +57,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { result.textAlignment = .center result.lineBreakMode = .byCharWrapping result.text = getUserHexEncodedPublicKey() + return result }() @@ -55,6 +65,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { let result = Button(style: .prominentOutline, size: .medium) result.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal) result.addTarget(self, action: #selector(copyPublicKey), for: UIControl.Event.touchUpInside) + return result }() @@ -62,6 +73,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { let result = UIStackView() result.axis = .vertical result.alignment = .fill + return result }() @@ -71,6 +83,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { result.setTitleColor(Colors.text, for: UIControl.State.normal) result.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize) result.addTarget(self, action: #selector(sendInvitation), for: UIControl.Event.touchUpInside) + return result }() @@ -80,6 +93,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { result.setTitleColor(Colors.text, for: UIControl.State.normal) result.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize) result.addTarget(self, action: #selector(openFAQ), for: UIControl.Event.touchUpInside) + return result }() @@ -87,8 +101,9 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { let result = UIButton() result.setTitle(NSLocalizedString("vc_settings_survey_button_title", comment: ""), for: UIControl.State.normal) result.setTitleColor(Colors.text, for: UIControl.State.normal) - result.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize) + result.titleLabel?.font = .boldSystemFont(ofSize: Values.smallFontSize) result.addTarget(self, action: #selector(openSurvey), for: UIControl.Event.touchUpInside) + return result }() @@ -98,6 +113,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { result.setTitleColor(Colors.text, for: UIControl.State.normal) result.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize) result.addTarget(self, action: #selector(shareLogs), for: UIControl.Event.touchUpInside) + return result }() @@ -107,6 +123,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { result.setTitleColor(Colors.text, for: UIControl.State.normal) result.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize) result.addTarget(self, action: #selector(helpTranslate), for: UIControl.Event.touchUpInside) + return result }() @@ -114,6 +131,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { let result = UIImageView() result.set(.height, to: 24) result.contentMode = .scaleAspectFit + return result }() @@ -127,27 +145,39 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { let version = Bundle.main.infoDictionary!["CFBundleShortVersionString"]! let buildNumber = Bundle.main.infoDictionary!["CFBundleVersion"]! result.text = "Version \(version) (\(buildNumber))" + return result }() - // MARK: Settings + // MARK: - Settings + private static let buttonHeight = isIPhone5OrSmaller ? CGFloat(52) : CGFloat(75) - // MARK: Lifecycle + // MARK: - Lifecycle + override func viewDidLoad() { super.viewDidLoad() + 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 = Storage.shared.getUser()?.name + displayNameLabel.text = profile.name + // Display name container let displayNameContainer = UIView() displayNameContainer.accessibilityLabel = "Edit display name text field" @@ -160,28 +190,34 @@ 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 = UIDevice.current.isIPad ? Values.iPadButtonSpacing : Values.mediumSpacing buttonContainer.distribution = .fillEqually + if (UIDevice.current.isIPad) { buttonContainer.layoutMargins = UIEdgeInsets(top: 0, left: Values.iPadButtonContainerMargin, bottom: 0, right: Values.iPadButtonContainerMargin) buttonContainer.isLayoutMarginsRelativeArrangement = true } // User session id container let userPublicKeyContainer = UIView(wrapping: publicKeyLabel, withInsets: .zero, shouldAdaptForIPadWithWidth: Values.iPadUserSessionIdContainerWidth) + // Top stack view let topStackView = UIStackView(arrangedSubviews: [ headerStackView, separator, userPublicKeyContainer, buttonContainer ]) topStackView.axis = .vertical @@ -189,10 +225,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() @@ -200,6 +238,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 @@ -208,6 +247,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 @@ -222,30 +262,39 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { let result = UIView() result.backgroundColor = Colors.separator result.set(.height, to: Values.separatorThickness) + return result } + func getSettingButton(withTitle title: String, color: UIColor, action selector: Selector) -> UIButton { let button = UIButton() button.setTitle(title, for: UIControl.State.normal) button.setTitleColor(color, for: UIControl.State.normal) button.titleLabel!.font = .boldSystemFont(ofSize: Values.mediumFontSize) button.titleLabel!.textAlignment = .center + func getImage(withColor color: UIColor) -> UIImage { let rect = CGRect(origin: CGPoint.zero, size: CGSize(width: 1, height: 1)) UIGraphicsBeginImageContext(rect.size) + let context = UIGraphicsGetCurrentContext()! context.setFillColor(color.cgColor) context.fill(rect) + let image = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() + return image! } + let backgroundColor = isLightMode ? UIColor(hex: 0xFCFCFC) : UIColor(hex: 0x1B1B1B) button.setBackgroundImage(getImage(withColor: backgroundColor), for: UIControl.State.normal) + let selectedColor = isLightMode ? UIColor(hex: 0xDFDFDF) : UIColor(hex: 0x0C0C0C) button.setBackgroundImage(getImage(withColor: selectedColor), for: UIControl.State.highlighted) button.addTarget(self, action: selector, for: UIControl.Event.touchUpInside) button.set(.height, to: SettingsVC.buttonHeight) + return button } @@ -268,6 +317,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)), @@ -275,12 +326,20 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { ] } - // MARK: General + // MARK: - General + @objc private func enableCopyButton() { copyButton.isUserInteractionEnabled = true - UIView.transition(with: copyButton, duration: 0.25, options: .transitionCrossDissolve, animations: { - self.copyButton.setTitle(NSLocalizedString("copy", comment: ""), for: UIControl.State.normal) - }, completion: nil) + + UIView.transition( + with: copyButton, + duration: 0.25, + options: .transitionCrossDissolve, + animations: { + self.copyButton.setTitle("copy".localized(), for: .normal) + }, + completion: nil + ) } func avatarActionSheetTitle() -> String? { return "Update Profile Picture" } @@ -288,16 +347,20 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { func hasClearAvatarAction() -> Bool { return false } func clearAvatarActionLabel() -> String { return "Clear" } - // MARK: Updating + // MARK: - Updating + private func handleIsEditingDisplayNameChanged() { updateNavigationBarButtons() + UIView.animate(withDuration: 0.25) { self.displayNameLabel.alpha = self.isEditingDisplayName ? 0 : 1 self.displayNameTextField.alpha = self.isEditingDisplayName ? 1 : 0 } + if isEditingDisplayName { displayNameTextField.becomeFirstResponder() - } else { + } + else { displayNameTextField.resignFirstResponder() } } @@ -309,101 +372,128 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { cancelButton.accessibilityLabel = "Cancel button" cancelButton.isAccessibilityElement = true navigationItem.leftBarButtonItem = cancelButton + let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(handleSaveDisplayNameButtonTapped)) doneButton.tintColor = Colors.text doneButton.accessibilityLabel = "Done button" doneButton.isAccessibilityElement = true navigationItem.rightBarButtonItem = doneButton - } else { + } + else { let closeButton = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) closeButton.tintColor = Colors.text closeButton.accessibilityLabel = "Close button" 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) - } 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 + + 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) } } - func avatarDidChange(_ image: UIImage) { - let maxSize = Int(kOWSProfileManager_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 = displayNameToBeUploaded ?? Storage.shared.getUser()?.name - let profilePicture = profilePictureToBeUploaded ?? OWSProfileManager.shared().profileAvatar(forRecipientId: 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 + let name: String? = (displayNameToBeUploaded ?? Profile.fetchOrCreateCurrentUser().name) + let imageFilePath: String? = (profilePictureFilePath ?? ProfileManager.profileAvatarFilepath(id: getUserHexEncodedPublicKey())) + + ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded] modalActivityIndicator in + ProfileManager.updateLocal( + queue: DispatchQueue.global(qos: .default), + profileName: (name ?? ""), + image: profilePicture, + imageFilePath: imageFilePath, + requiredSync: true, + success: { db, updatedProfile in + 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 isUpdatingProfilePicture { + userDefaults[.lastProfilePictureUpdate] = Date() + } + + 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?.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: "BUTTON_OK".localized(), 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?.presentAlert(alert) } } - }, requiresSync: true) + ) } } @objc override internal func handleAppModeChangedNotification(_ notification: Notification) { super.handleAppModeChangedNotification(notification) + updateNavigationBarButtons() settingButtonsStackView.arrangedSubviews.forEach { settingButton in settingButtonsStackView.removeArrangedSubview(settingButton) @@ -420,7 +510,8 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate { logoImageView.image = UIImage(named: logoName)! } - // MARK: Interaction + // MARK: - Interaction + @objc private func close() { dismiss(animated: true, completion: nil) } @@ -467,12 +558,17 @@ 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 displayNameToBeUploaded = displayName - updateProfile(isUpdatingDisplayName: true, isUpdatingProfilePicture: false) + updateProfile( + profilePicture: nil, + profilePictureFilePath: nil, + isUpdatingDisplayName: true, + isUpdatingProfilePicture: false + ) } @objc private func showEditProfilePictureUI() { @@ -523,6 +619,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/Settings/ShareLogsModal.swift b/Session/Settings/ShareLogsModal.swift index 72c3ae22e..cd4723d05 100644 --- a/Session/Settings/ShareLogsModal.swift +++ b/Session/Settings/ShareLogsModal.swift @@ -1,18 +1,22 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit import SignalUtilitiesKit -final class ShareLogsModal : Modal { +final class ShareLogsModal: Modal { + + // MARK: - Lifecycle - // MARK: Lifecycle init() { super.init(nibName: nil, bundle: nil) } override init(nibName: String?, bundle: Bundle?) { - preconditionFailure("Use init(url:) instead.") + preconditionFailure("Use init() instead.") } required init?(coder: NSCoder) { - preconditionFailure("Use init(url:) instead.") + preconditionFailure("Use init() instead.") } override func populateContentView() { @@ -22,6 +26,7 @@ final class ShareLogsModal : Modal { titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) titleLabel.text = NSLocalizedString("modal_share_logs_title", comment: "") titleLabel.textAlignment = .center + // Message let messageLabel = UILabel() messageLabel.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) @@ -30,6 +35,7 @@ final class ShareLogsModal : Modal { messageLabel.numberOfLines = 0 messageLabel.lineBreakMode = .byWordWrapping messageLabel.textAlignment = .center + // Open button let shareButton = UIButton() shareButton.set(.height, to: Values.mediumButtonHeight) @@ -39,15 +45,18 @@ final class ShareLogsModal : Modal { shareButton.setTitleColor(Colors.text, for: UIControl.State.normal) shareButton.setTitle(NSLocalizedString("share", comment: ""), for: UIControl.State.normal) shareButton.addTarget(self, action: #selector(shareLogs), for: UIControl.Event.touchUpInside) + // Button stack view let buttonStackView = UIStackView(arrangedSubviews: [ cancelButton, shareButton ]) buttonStackView.axis = .horizontal buttonStackView.spacing = Values.mediumSpacing buttonStackView.distribution = .fillEqually + // Content stack view let contentStackView = UIStackView(arrangedSubviews: [ titleLabel, messageLabel ]) contentStackView.axis = .vertical contentStackView.spacing = Values.largeSpacing + // Main stack view let spacing = Values.largeSpacing - Values.smallFontSize / 2 let mainStackView = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) @@ -60,17 +69,26 @@ final class ShareLogsModal : Modal { contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: spacing) } - // 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/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/ConversationCell.swift b/Session/Shared/ConversationCell.swift deleted file mode 100644 index a9fa91443..000000000 --- a/Session/Shared/ConversationCell.swift +++ /dev/null @@ -1,414 +0,0 @@ -import UIKit -import SessionUIKit - -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) - result.textColor = Colors.text - result.lineBreakMode = .byTruncatingTail - return result - }() - - private lazy var unreadCountView: UIView = { - let result = 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() - result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) - result.textColor = Colors.text - result.textAlignment = .center - return result - }() - - private lazy var hasMentionView: UIView = { - let result = 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() - 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)) - 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() - 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() - 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() - result.contentMode = .scaleAspectFit - result.layer.cornerRadius = ConversationCell.statusIndicatorSize / 2 - result.layer.masksToBounds = true - return result - }() - - private lazy var topLabelStackView: UIStackView = { - let result = 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() - 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: Updating for search results - private func updateForSearchResult() { - AssertIsOnMainThread() - guard let thread = threadViewModel?.threadRecord else { return } - profilePictureView.update(for: thread) - isPinnedIcon.isHidden = true - unreadCountView.isHidden = true - hasMentionView.isHidden = true - } - - public func configureForRecent() { - displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(), attributes: [.foregroundColor:Colors.text]) - bottomLabelStackView.isHidden = false - let snippet = String(format: NSLocalizedString("RECENT_SEARCH_LAST_MESSAGE_DATETIME", comment: ""), DateUtil.formatDate(forDisplay: threadViewModel.lastMessageDate)) - 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) - 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) { - 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 != 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 - private func update() { - AssertIsOnMainThread() - guard let thread = threadViewModel?.threadRecord else { return } - backgroundColor = threadViewModel.isPinned ? Colors.cellPinned : Colors.cellBackground - - if thread.isBlocked() { - 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 - } - 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() - typingIndicatorView.isHidden = true - typingIndicatorView.stopAnimation() - } - statusIndicatorView.backgroundColor = nil - let lastMessage = threadViewModel.lastMessageForInbox - if let lastMessage = lastMessage as? TSOutgoingMessage, !lastMessage.isCallMessage { - - let status = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: lastMessage) - - 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 - } - } - - 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 nil - } - - 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 - } - } - - private func getDisplayName() -> String { - if threadViewModel.isGroupThread { - if threadViewModel.name.isEmpty { - return "Unknown Group" - } - else { - return threadViewModel.name - } - } - else { - if threadViewModel.threadRecord.isNoteToSelf() { - return NSLocalizedString("NOTE_TO_SELF", comment: "") - } - 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) - } - } - } - - private func getSnippet() -> 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 { - 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 = 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 ])) - } - 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 ])) - } - return result - } -} diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift new file mode 100644 index 000000000..ebf55e4c2 --- /dev/null +++ b/Session/Shared/FullConversationCell.swift @@ -0,0 +1,585 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SignalUtilitiesKit +import SessionMessagingKit + +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 + + return result + }() + + 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) + + 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 = 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) + } + + 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: 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 + ), + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, + 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, + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, + 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 ?? ""), + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, + searchText: searchText.lowercased(), + fontSize: Values.smallFontSize + ) + } + } + + // 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 + } + + 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 + ), + showMultiAvatarForClosedGroup: true + ) + 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 + + 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 = true + } + } + + // 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) { + 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, + threadContactDisplayName: cellViewModel.threadContactName(), + authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant), + attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo, + attachmentCount: cellViewModel.interactionAttachmentCount, + isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true) + ), + threadVariant: cellViewModel.threadVariant, + currentUserPublicKey: cellViewModel.currentUserPublicKey, + currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey + ), + attributes: [ + .font: font, + .foregroundColor: Colors.text + ] + )) + + return result + } + + private func getHighlightedSnippet( + content: String, + authorName: String? = nil, + currentUserPublicKey: String, + currentUserBlindedPublicKey: String?, + 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, + currentUserPublicKey: currentUserPublicKey, + currentUserBlindedPublicKey: currentUserBlindedPublicKey + ) + 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 ] + ) + + 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/HighlightMentionBackgroundView.swift b/Session/Shared/HighlightMentionBackgroundView.swift new file mode 100644 index 000000000..3a7db4eaa --- /dev/null +++ b/Session/Shared/HighlightMentionBackgroundView.swift @@ -0,0 +1,158 @@ +// 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..= 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/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/Shared/OWSScreenLockUI.m b/Session/Shared/OWSScreenLockUI.m index e7cb44121..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 @@ -348,11 +349,11 @@ NS_ASSUME_NONNULL_BEGIN return ScreenLockUIStateNone; } - if (Environment.shared.isRequestingPermission) { + if (SMKEnvironment.shared.isRequestingPermission) { return ScreenLockUIStateNone; } - - if (Environment.shared.preferences.screenSecurityIsEnabled) { + + if ([SMKPreferences isScreenSecurityEnabled]) { OWSLogVerbose(@"desiredUIState: screen protection 4."); return ScreenLockUIStateScreenProtection; } else { 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/Shared/UserCell.swift b/Session/Shared/UserCell.swift index 8fd199a28..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.publicKey = publicKey - profilePictureView.update() - displayNameLabel.text = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey + // MARK: - Updating + + 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 b62104da3..a2946f565 100644 --- a/Session/Shared/UserSelectionVC.swift +++ b/Session/Shared/UserSelectionVC.swift @@ -1,31 +1,37 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 = ContactUtilities.getAllContacts() - result.removeAll { usersToExclude.contains($0) } - return result + private lazy var users: [Profile] = { + return Profile + .fetchAllContactProfiles(excluding: usersToExclude) }() - // 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 @@ -47,29 +53,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/Sheets & Modals/Modal.swift b/Session/Sheets & Modals/Modal.swift index f77cb4bc1..9bd7c165f 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/Session/Utilities/AccountManager.swift b/Session/Utilities/AccountManager.swift deleted file mode 100644 index 60a6ed448..000000000 --- a/Session/Utilities/AccountManager.swift +++ /dev/null @@ -1,118 +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 - - var profileManager: OWSProfileManager { - return OWSProfileManager.shared() - } - - 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("") - let job = SyncPushTokensJob(accountManager: self, preferences: self.preferences) - job.uploadOnlyIfStale = false - return job.run() - } - - 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/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/Session/Utilities/AvatarViewHelper.h b/Session/Utilities/AvatarViewHelper.h index 82ecc282d..bf9ba6832 100644 --- a/Session/Utilities/AvatarViewHelper.h +++ b/Session/Utilities/AvatarViewHelper.h @@ -8,14 +8,12 @@ NS_ASSUME_NONNULL_BEGIN @class AvatarViewHelper; @class OWSContactsManager; -@class SignalAccount; -@class TSThread; @protocol AvatarViewHelperDelegate - (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 178f0a05f..12e11c82f 100644 --- a/Session/Utilities/AvatarViewHelper.m +++ b/Session/Utilities/AvatarViewHelper.m @@ -9,9 +9,6 @@ #import -#import -#import -#import #import NS_ASSUME_NONNULL_BEGIN @@ -126,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/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index bbaf4413e..03f575c48 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -1,112 +1,186 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit import SessionSnodeKit +import SessionMessagingKit +import SessionUtilitiesKit -@objc(LKBackgroundPoller) -public final class BackgroundPoller : NSObject { +public final class BackgroundPoller { private static var promises: [Promise] = [] + private static var isValid: Bool = false - private override init() { } - - @objc(pollWithCompletionHandler:) public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + BackgroundPoller.isValid = true + 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)") + .appending(pollForMessages()) + .appending(contentsOf: pollForClosedGroupMessages()) + .appending( + 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 + try OpenGroup + .select(.server) + .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.isActive) + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + } + .defaulting(to: []) + .map { server in + let poller: OpenGroupAPI.Poller = OpenGroupAPI.Poller(for: server) + poller.stop() + + return poller.poll( + isBackgroundPoll: true, + isBackgroundPollerValid: { BackgroundPoller.isValid }, + isPostCapabilitiesRetry: false + ) + } + ) + + // 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() + BackgroundPoller.isValid = false + + guard promises.contains(where: { !$0.isResolved }) else { return } + + SNLog("Background poll failed due to manual timeout") completionHandler(.failed) } + + 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) + } + .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) + } } private static func pollForMessages() -> Promise { - let userPublicKey = getUserHexEncodedPublicKey() + let userPublicKey: String = getUserHexEncodedPublicKey() return getMessages(for: userPublicKey) } private static func pollForClosedGroupMessages() -> [Promise] { - let publicKeys = Storage.shared.getUserClosedGroupPublicKeys() - return publicKeys.map { getClosedGroupMessages(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 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) + } + .defaulting(to: []) + .map { groupPublicKey in + ClosedGroupPoller.poll( + groupPublicKey, + on: DispatchQueue.main, + maxRetryCount: 0, + isBackgroundPoll: true, + isBackgroundPollValid: { BackgroundPoller.isValid } + ) + } } 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) { - SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey).then(on: DispatchQueue.main) { rawResponse -> Promise in - let (messages, lastRawMessage) = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: publicKey) - var processedMessages: [JSON] = [] - 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) - processedMessages.append(json) - return job.execute() - } - // Now that the MessageReceiveJob's have been created we can update the `lastMessageHash` value & `receivedMessageHashes` - SnodeAPI.updateLastMessageHashValueIfPossible(for: snode, namespace: SnodeAPI.defaultNamespace, associatedWith: publicKey, from: lastRawMessage) - SnodeAPI.updateReceivedMessages(from: processedMessages, associatedWith: publicKey) - - return when(fulfilled: promises) // The promise returned by MessageReceiveJob never rejects - } - } - } - } - - 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 namespaces: [Int] = [] - let promises: [SnodeAPI.RawResponsePromise] = { - if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 { - namespaces = [ SnodeAPI.closedGroupNamespace ] - return [ SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey, authenticated: false) ] - } - if SnodeAPI.hardfork >= 19 { - namespaces = [ SnodeAPI.defaultNamespace, SnodeAPI.closedGroupNamespace ] - return [ SnodeAPI.getRawClosedGroupMessagesFromDefaultNamespace(from: snode, associatedWith: publicKey), - SnodeAPI.getRawMessages(from: snode, associatedWith: publicKey, authenticated: false)] - } - namespaces = [ SnodeAPI.defaultNamespace ] - return [ SnodeAPI.getRawClosedGroupMessagesFromDefaultNamespace(from: snode, associatedWith: publicKey) ] - }() - - 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 rawResponse) = result { - let (messages, lastRawMessage) = SnodeAPI.parseRawMessagesResponse(rawResponse, from: snode, associatedWith: publicKey) - var processedMessages: [JSON] = [] - let jobPromises = 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) - processedMessages.append(json) - return job.execute() - } - // Now that the MessageReceiveJob's have been created we can update the `lastMessageHash` value & `receivedMessageHashes` - SnodeAPI.updateLastMessageHashValueIfPossible(for: snode, namespace: namespaces[index], associatedWith: publicKey, from: lastRawMessage) - SnodeAPI.updateReceivedMessages(from: processedMessages, associatedWith: publicKey) - promises += jobPromises + return SnodeAPI.getSwarm(for: publicKey) + .then(on: DispatchQueue.main) { swarm -> Promise in + guard let snode = swarm.randomElement() else { throw SnodeAPIError.generic } + + return SnodeAPI.getMessages(from: snode, associatedWith: publicKey) + .then(on: DispatchQueue.main) { messages -> Promise in + guard !messages.isEmpty, BackgroundPoller.isValid else { return Promise.value(()) } + + var jobsToRun: [Job] = [] + + Storage.shared.write { db in + 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).") + } + + return nil + } + } + .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.map { $0.messageInfo }, + 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) + } } - index += 1 + + 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) } - return when(fulfilled: promises) // The promise returned by MessageReceiveJob never rejects - } } - } } } 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/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/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/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/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/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index fb0fb0d82..f5876d5d6 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -1,39 +1,107 @@ +// 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 { - return highlightMentions(in: string, isOutgoingMessage: false, threadID: threadID, attributes: [:]).string // isOutgoingMessage and attributes are irrelevant +public enum MentionUtilities { + 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 } - @objc public static func highlightMentions(in string: String, isOutgoingMessage: Bool, threadID: String, attributes: [NSAttributedString.Key:Any]) -> NSAttributedString { - let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) + public static func highlightMentions( + in string: String, + threadVariant: SessionThread.Variant, + currentUserPublicKey: String?, + currentUserBlindedPublicKey: String?, + 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) + } + 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 - let context: Contact.Context = (openGroupV2 != 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)") - 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)) + var lastMatchEnd: Int = 0 + var mentions: [(range: NSRange, isCurrentUser: Bool)] = [] + let currentUserPublicKeys: Set = [ + currentUserPublicKey, + currentUserBlindedPublicKey + ] + .compactMap { $0 } + .asSet() + + 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 @ + let isCurrentUser: Bool = currentUserPublicKeys.contains(publicKey) + + 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: "@\(targetString)") + lastMatchEnd = (match.range.location + targetString.utf16.count) + + mentions.append(( + // + 1 to include the @ + range: NSRange(location: match.range.location, length: targetString.utf16.count + 1), + isCurrentUser: isCurrentUser + )) } - let result = NSMutableAttributedString(string: string, attributes: attributes) + + let sizeDiff: CGFloat = (Values.smallFontSize / Values.mediumFontSize) + let result: NSMutableAttributedString = NSMutableAttributedString(string: string, attributes: attributes) mentions.forEach { mention in - 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/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/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 032e9d128..9b0b12090 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -1,7 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Sodium +import Curve25519Kit import SessionMessagingKit enum MockDataGenerator { @@ -66,190 +68,365 @@ enum MockDataGenerator { } } - static func generateMockData() { + // MARK: - Generation + + static var printProgress: Bool = true + static var hasStartedGenerationThisRun: Bool = false + + 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.fetch(for: "MockDatabaseThread", using: transaction) + guard !hasStartedGenerationThisRun && !(try! SessionThread.exists(db, id: "MockDatabaseThread")) else { + hasStartedGenerationThisRun = true + return } - - guard existingMockDataThread == nil else { 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): + /// 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 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(db) + 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 } + logProgress("", "Start") + + // First create the thread used to indicate that the mock data has been generated + _ = try? SessionThread.fetchOrCreate(db, id: "MockDatabaseThread", variant: .contact) + + // 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) - // First create the thread used to indicate that the mock data has been generated - _ = 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 timestampNow: TimeInterval = Date().timeIntervalSince1970 - let userSessionId: String = getUserHexEncodedPublicKey() - - // MARK: - -- DM Thread - var dmThreadRandomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: dmRandomSeed) - - (0.. 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/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 b6410c284..6e33cb009 100644 --- a/Session/Utilities/UIApplication+OWS.swift +++ b/Session/Utilities/UIApplication+OWS.swift @@ -1,33 +1,30 @@ -// -// 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 - } - Logger.error("findFrontmostViewController: \(window)") - guard let viewController = window.rootViewController else { + guard let window: UIWindow = CurrentAppContext().mainWindow else { return nil } + + guard let viewController: UIViewController = window.rootViewController else { owsFailDebug("Missing root view controller.") return nil } return viewController.findFrontmostViewController(ignoringAlerts) } - public func openSystemSettings() { - openURL(URL(string: UIApplication.openSettingsURLString)!) + func openSystemSettings() { + open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) } - } 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/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/Calls/CallManagerProtocol.swift b/SessionMessagingKit/Calls/CallManagerProtocol.swift new file mode 100644 index 000000000..d4cc72d52 --- /dev/null +++ b/SessionMessagingKit/Calls/CallManagerProtocol.swift @@ -0,0 +1,14 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import CallKit + +public protocol CallManagerProtocol { + var currentCall: CurrentCallProtocol? { get set } + + func reportCurrentCallEnded(reason: CXCallEndedReason?) + + func showCallUIForCall(caller: String, uuid: String, mode: CallMode, interactionId: Int64?) + func handleAnswerMessage(_ message: CallMessage) + func dismissAllCallUI() +} diff --git a/SessionMessagingKit/Calls/CallMode.swift b/SessionMessagingKit/Calls/CallMode.swift new file mode 100644 index 000000000..578b89ad4 --- /dev/null +++ b/SessionMessagingKit/Calls/CallMode.swift @@ -0,0 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum CallMode { + case offer + case answer +} diff --git a/SessionMessagingKit/Calls/CurrentCallProtocol.swift b/SessionMessagingKit/Calls/CurrentCallProtocol.swift new file mode 100644 index 000000000..6968116db --- /dev/null +++ b/SessionMessagingKit/Calls/CurrentCallProtocol.swift @@ -0,0 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import WebRTC + +public protocol CurrentCallProtocol { + var uuid: String { get } + var callId: UUID { get } + var webRTCSession: WebRTCSession { get } + var hasStartedConnecting: Bool { get set } + + func updateCallMessage(mode: EndCallMode) + func didReceiveRemoteSDP(sdp: RTCSessionDescription) + func startSessionCall(_ db: Database) +} diff --git a/SessionMessagingKit/Calls/EndCallMode.swift b/SessionMessagingKit/Calls/EndCallMode.swift new file mode 100644 index 000000000..fbf218ca1 --- /dev/null +++ b/SessionMessagingKit/Calls/EndCallMode.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum EndCallMode { + case local + case remote + case unanswered + case answeredElsewhere +} diff --git a/SessionMessagingKit/Calls/TurnServerInfo.swift b/SessionMessagingKit/Calls/TurnServerInfo.swift index fe51837c2..50420ca97 100644 --- a/SessionMessagingKit/Calls/TurnServerInfo.swift +++ b/SessionMessagingKit/Calls/TurnServerInfo.swift @@ -1,6 +1,7 @@ // Copyright © 2021 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit struct TurnServerInfo { @@ -9,27 +10,20 @@ struct TurnServerInfo { let urls: [String] init?(attributes: JSON, random: Int? = nil) { - if let passwordAttribute = (attributes["password"] as? String) { - password = passwordAttribute - } else { + guard + let passwordAttribute = attributes["password"] as? String, + let usernameAttribute = attributes["username"] as? String, + let urlsAttribute = attributes["urls"] as? [String] + else { return nil } - - if let usernameAttribute = attributes["username"] as? String { - username = usernameAttribute - } else { - return nil - } - - if let urlsAttribute = attributes["urls"] as? [String] { - if let random = random { - urls = Array(urlsAttribute.shuffled()[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) } } + + Storage.shared + .writeAsync { 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) + + Storage.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 +261,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() + + 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.") - 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/SessionMessagingKit/Common Networking/Header.swift b/SessionMessagingKit/Common Networking/Header.swift new file mode 100644 index 000000000..6c33e41a3 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Header.swift @@ -0,0 +1,22 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum Header: String { + case authorization = "Authorization" + case contentType = "Content-Type" + case contentDisposition = "Content-Disposition" + + case sogsPubKey = "X-SOGS-Pubkey" + case sogsNonce = "X-SOGS-Nonce" + case sogsTimestamp = "X-SOGS-Timestamp" + case sogsSignature = "X-SOGS-Signature" +} + +// 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/FileUploadResponse.swift b/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift new file mode 100644 index 000000000..48cd04944 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Models/FileUploadResponse.swift @@ -0,0 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +public struct FileUploadResponse: Codable { + 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/QueryParam.swift b/SessionMessagingKit/Common Networking/QueryParam.swift new file mode 100644 index 000000000..7a1fe0f18 --- /dev/null +++ b/SessionMessagingKit/Common Networking/QueryParam.swift @@ -0,0 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum QueryParam: String { + case publicKey = "public_key" + case fromServerId = "from_server_id" + + 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..f32620bb2 --- /dev/null +++ b/SessionMessagingKit/Common Networking/Request.swift @@ -0,0 +1,100 @@ +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? + + // MARK: - Initialization + + init( + method: HTTP.Verb = .get, + server: String, + endpoint: Endpoint, + queryParameters: [QueryParam: String] = [:], + headers: [Header: String] = [:], + body: T? = nil + ) { + self.method = method + self.server = server + self.endpoint = endpoint + self.queryParameters = queryParameters + self.headers = headers + self.body = body + } + + // 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 + } +} + +extension Request: Equatable where T: Equatable {} diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 2af8d4c48..22a726c2e 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -1,18 +1,38 @@ - -@objc -public final class SNMessagingKitConfiguration : NSObject { - public let storage: SessionMessagingKitStorageProtocol - - @objc public static var shared: SNMessagingKitConfiguration! - - fileprivate init(storage: SessionMessagingKitStorageProtocol) { - self.storage = storage - } -} +import Foundation +import SessionUtilitiesKit public enum SNMessagingKit { // Just to make the external API nice - - public static func configure(storage: SessionMessagingKitStorageProtocol) { - SNMessagingKitConfiguration.shared = SNMessagingKitConfiguration(storage: storage) + public static func migrations() -> TargetMigrations { + return TargetMigrations( + identifier: .messagingKit, + migrations: [ + [ + _001_InitialSetupMigration.self, + _002_SetupStandardJobs.self + ], + [ + _003_YDBToGRDBMigration.self + ], + [ + _004_RemoveLegacyYDB.self + ] + ] + ) + } + + public static func configure() { + // Configure the job executors + JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages) + 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) + 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) + JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts) + JobRunner.add(executor: AttachmentDownloadJob.self, for: .attachmentDownload) + JobRunner.add(executor: AttachmentUploadJob.self, for: .attachmentUpload) } } diff --git a/SessionMessagingKit/Contacts/Contact.swift b/SessionMessagingKit/Contacts/Contact.swift deleted file mode 100644 index 47b303ecf..000000000 --- a/SessionMessagingKit/Contacts/Contact.swift +++ /dev/null @@ -1,127 +0,0 @@ -import Foundation - -@objc(SNContact) -public class Contact : 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? 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/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift new file mode 100644 index 000000000..a0c268851 --- /dev/null +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -0,0 +1,1752 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import YapDatabase +import SignalCoreKit +import SessionUtilitiesKit + +public enum SMKLegacy { + // MARK: - Collections and Keys + + 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" + + public static let contactCollection = "LokiContactCollection" + public static let threadCollection = "TSThread" + internal static let disappearingMessagesCollection = "OWSDisappearingMessagesConfiguration" + + 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" + + 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" + 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" + internal static let messageSendJobCollection = "MessageSendJobCollection" + 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" + internal static let additionalPreferencesCollection = "SSKPreferences" + 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 preferencesKeyAreCallsEnabled = "areCallsEnabled" + 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 preferencesKeyIsReadyForAppExtensions = "isReadyForAppExtensions_5" + + internal static let readReceiptManagerCollection = "OWSReadReceiptManagerCollection" + internal static let readReceiptManagerAreReadReceiptsEnabled = "areReadReceiptsEnabled" + + 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" + + internal static let userDefaultsHasHiddenMessageRequests = "hasHiddenMessageRequests" + internal static let userDefaultsHasViewedSeedKey = "hasViewedSeed" + + // 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 + case sogsV4Migration = "005" // Ignored (deletes unused data, replaced by not migrating) + } + + // MARK: - Contact + + @objc(SNContact) + public class _Contact: NSObject, NSCoding { + 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 + + 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") + } + } + + // MARK: - Message + + /// 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? // Not used for anything + 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: Message? = nil) throws -> Message { + let result: Message = (instance ?? 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.serverHash = self.serverHash + + return result + } + } + + // MARK: - Visible Message + + @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: Message? = nil) throws -> Message { + return try super.toNonLegacy( + VisibleMessage( + syncTarget: syncTarget, + text: text, + attachmentIds: attachmentIDs, + quote: quote?.toNonLegacy(), + linkPreview: linkPreview?.toNonLegacy(), + profile: profile?.toNonLegacy(), + openGroupInvitation: openGroupInvitation?.toNonLegacy() + ) + ) + } + } + + // MARK: - Quote + + @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() -> VisibleMessage.VMQuote { + return VisibleMessage.VMQuote( + timestamp: (timestamp ?? 0), + publicKey: (publicKey ?? ""), + text: text, + attachmentId: attachmentID + ) + } + } + + // MARK: - Link Preview + + @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() -> VisibleMessage.VMLinkPreview { + return VisibleMessage.VMLinkPreview( + title: title, + url: (url ?? ""), + attachmentId: attachmentID + ) + } + } + + // MARK: - Profile + + @objc(SNProfile) + 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() -> VisibleMessage.VMProfile { + return VisibleMessage.VMProfile( + displayName: (displayName ?? ""), + profileKey: profileKey, + profilePictureUrl: profilePictureURL + ) + } + } + + // MARK: - Open Group Invitation + + @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() -> VisibleMessage.VMOpenGroupInvitation { + return VisibleMessage.VMOpenGroupInvitation( + name: (name ?? ""), + url: (url ?? "") + ) + } + } + + // 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 + + 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: Message? = nil) throws -> Message { + return try super.toNonLegacy( + ReadReceipt( + timestamps: (timestamps ?? []) + ) + ) + } + } + + // MARK: - Typing Indicator + + @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: Message? = nil) throws -> Message { + return try super.toNonLegacy( + TypingIndicator( + kind: TypingIndicator.Kind( + rawValue: (rawKind ?? TypingIndicator.Kind.stopped.rawValue) + ) + .defaulting(to: .stopped) + ) + ) + } + } + + // MARK: - Closed Group Control Message + + @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: Message? = nil) throws -> Message { + return try super.toNonLegacy( + 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 StorageError.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 StorageError.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 StorageError.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 StorageError.migrationFailed + } + + return .nameChange( + name: name + ) + + case "membersAdded": + guard let members: [Data] = self.members else { + SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + throw StorageError.migrationFailed + } + + return .membersAdded(members: members) + + case "membersRemoved": + guard let members: [Data] = self.members else { + SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + throw StorageError.migrationFailed + } + + return .membersRemoved(members: members) + + case "memberLeft": return .memberLeft + case "encryptionKeyPairRequest": return .encryptionKeyPairRequest + default: throw StorageError.migrationFailed + } + }() + ) + ) + } + } + + // MARK: - Data Extraction Notification + + @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: Message? = nil) throws -> Message { + return try super.toNonLegacy( + 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 StorageError.migrationFailed + } + + return .mediaSaved(timestamp: timestamp) + + default: throw StorageError.migrationFailed + } + }() + ) + ) + } + } + + // MARK: - Expiration Timer Update + + @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: Message? = nil) throws -> Message { + return try super.toNonLegacy( + ExpirationTimerUpdate( + syncTarget: syncTarget, + duration: (duration ?? 0) + ) + ) + } + } + + // MARK: - Configuration Message + + @objc(SNConfigurationMessage) + 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<_CMContact> = [] + + // MARK: NSCoding + + public required init?(coder: NSCoder) { + super.init(coder: coder) + 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<_CMContact>? { 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: Message? = nil) throws -> Message { + return try super.toNonLegacy( + ConfigurationMessage( + displayName: displayName, + profilePictureUrl: profilePictureURL, + profileKey: profileKey, + closedGroups: closedGroups + .map { $0.toNonLegacy() } + .asSet(), + openGroups: openGroups, + contacts: contacts + .map { $0.toNonLegacy() } + .asSet() + ) + ) + } + } + + // MARK: - Config Message Closed Group + + @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() -> ConfigurationMessage.CMClosedGroup { + return ConfigurationMessage.CMClosedGroup( + publicKey: publicKey, + name: name, + encryptionKeyPublicKey: encryptionKeyPair.publicKey, + encryptionKeySecretKey: encryptionKeyPair.privateKey, + members: members, + admins: admins, + expirationTimer: expirationTimer + ) + } + } + + // MARK: - Config Message Contact + + @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() -> ConfigurationMessage.CMContact { + return ConfigurationMessage.CMContact( + publicKey: publicKey, + displayName: displayName, + profilePictureUrl: profilePictureURL, + profileKey: profileKey, + hasIsApproved: hasIsApproved, + isApproved: isApproved, + hasIsBlocked: hasIsBlocked, + isBlocked: isBlocked, + hasDidApproveMe: hasDidApproveMe, + didApproveMe: didApproveMe + ) + } + } + + // MARK: - Unsend Request + + @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: Message? = nil) throws -> Message { + return try super.toNonLegacy( + UnsendRequest( + timestamp: (timestamp ?? 0), + author: (author ?? "") + ) + ) + } + } + + // MARK: - Message Request Response + + @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: Message? = nil) throws -> Message { + return try super.toNonLegacy( + MessageRequestResponse( + isApproved: isApproved + ) + ) + } + } + + // 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) + 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? Bool) { + self.shouldBeVisible = hasEverHadMessage + } + else { + self.shouldBeVisible = (coder.decodeObject(forKey: "shouldBeVisible") as? Bool) + .defaulting(to: false) + } + + 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 + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Contact Thread + + @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)) + } + } + + // MARK: - Group Thread + + @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? Bool) + .defaulting(to: false) + + super.init(coder: coder) + } + } + + // MARK: - Group Model + + @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: - 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) + 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 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.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) < 1648000000000 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) + } + } + + // 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: - Data Extraction Info Message + + @objc(SNDataExtractionNotificationInfoMessage) + public final class _DataExtractionNotificationInfoMessage: _DBInfoMessage { + } + + // MARK: - Attachments + + @objc(TSAttachment) + internal class _Attachment: NSObject, NSCoding { + public enum _AttachmentType: Int { + case `default` + case voiceMessage + } + + 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) } + 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 + 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) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + @objc(TSAttachmentPointer) + internal class _AttachmentPointer: _Attachment { + public enum _State: Int { + case enqueued + case downloading + case failed + } + + public var state: _State + public var mostRecentFailureLocalizedText: String? + public var digest: Data? + public var mediaSize: CGSize + 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 { + 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) } + + 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") + } + } + + // MARK: - Notify Push Server Job + + @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 + + // 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) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + 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) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Message Receive Job + + @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? + 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? + // 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) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Message Send Job + + @objc(SNMessageSendJob) + 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?, + 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 _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 + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + + guard components.count == 2 else { return nil } + + let room = components[0] + let server = components[1] + destination = .openGroup( + roomToken: room, + server: server, + whisperTo: nil, + whisperMods: false, + fileIds: nil + ) + } + else { + return nil + } + + self.id = id + self.failureCount = ((coder.decodeObject(forKey: "failureCount") as? UInt) ?? 0) + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + + // 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 + } + } + + // MARK: - Attachment Upload Job + + @objc(AttachmentUploadJob) + 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 + + 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) { + fatalError("encode(with:) should never be called for legacy types") + } + } + + // MARK: - Attachment Download Job + + @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) { + fatalError("encode(with:) should never be called for legacy types") + } + } +} diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift new file mode 100644 index 000000000..61747d7ea --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -0,0 +1,381 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _001_InitialSetupMigration: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "initialSetup" + 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 + // 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() + .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) + } + + /// 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() + .primaryKey() + t.column(.variant, .integer).notNull() + t.column(.creationDateTimestamp, .double).notNull() + t.column(.shouldBeVisible, .boolean).notNull() + t.column(.isPinned, .boolean).notNull() + t.column(.messageDraft, .text) + t.column(.notificationSound, .integer) + t.column(.mutedUntilTimestamp, .double) + t.column(.onlyNotifyForMentions, .boolean) + .notNull() + .defaults(to: false) + } + + try db.create(table: DisappearingMessagesConfiguration.self) { t in + t.column(.threadId, .text) + .notNull() + .primaryKey() + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted + t.column(.isEnabled, .boolean) + .defaults(to: false) + .notNull() + t.column(.durationSeconds, .double) + .defaults(to: 0) + .notNull() + } + + try db.create(table: ClosedGroup.self) { t in + 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() + } + + /// 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() + .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() + .indexed() // Quicker querying + + t.uniqueKey([.publicKey, .secretKey, .receivedTimestamp]) + } + + 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() + t.column(.server, .text) + .indexed() // Quicker querying + .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(.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() + t.column(.pollFailureCount, .integer) + .notNull() + .defaults(to: 0) + } + + /// 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(.openGroupServer, .text) + .notNull() + .indexed() // Quicker querying + t.column(.variant, .text).notNull() + t.column(.isMissing, .boolean).notNull() + + 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 + // 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() + .indexed() // Quicker querying + 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(.messageUuid, .text) + .indexed() // Quicker querying + 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 + + t.column(.variant, .integer).notNull() + t.column(.body, .text) + t.column(.timestampMs, .integer) + .notNull() + .indexed() // Quicker querying + t.column(.receivedAtTimestampMs, .integer).notNull() + t.column(.wasRead, .boolean) + .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) + + t.column(.openGroupServerMessageId, .integer) + .indexed() // Quicker querying + t.column(.openGroupWhisperMods, .boolean) + .notNull() + .defaults(to: false) + t.column(.openGroupWhisperTo, .text) + + /// 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`]: + /// "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 #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]) + } + + /// 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() + .indexed() // Quicker querying + .references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted + t.column(.recipientId, .text) + .notNull() + .indexed() // Quicker querying + 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.primaryKey([.interactionId, .recipientId]) + } + + try db.create(table: Attachment.self) { t in + t.column(.id, .text) + .notNull() + .primaryKey() + t.column(.serverId, .text) + t.column(.variant, .integer).notNull() + t.column(.state, .integer) + .notNull() + .indexed() // Quicker querying + 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(.localRelativeFilePath, .text) + 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) + t.column(.encryptionKey, .blob) + t.column(.digest, .blob) + t.column(.caption, .text) + } + + try db.create(table: InteractionAttachment.self) { t in + t.column(.albumIndex, .integer).notNull() + 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() + .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 + } + + 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) + .indexed() // Quicker querying + .references(Attachment.self) // Managed via garbage collection + + t.primaryKey([.url, .timestamp]) + } + + try db.create(table: ControlMessageProcessRecord.self) { t in + 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, .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() + } + + 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 new file mode 100644 index 000000000..30485c730 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -0,0 +1,57 @@ +// 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 target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "SetupStandardJobs" + 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 + // will be added via migrations) + try autoreleasepool { + _ = try Job( + variant: .disappearingMessages, + behaviour: .recurringOnLaunch, + shouldBlock: true + ).inserted(db) + + _ = try Job( + variant: .failedMessageSends, + behaviour: .recurringOnLaunch, + shouldBlock: true + ).inserted(db) + + _ = try Job( + variant: .failedAttachmentDownloads, + behaviour: .recurringOnLaunch, + shouldBlock: true + ).inserted(db) + + _ = try Job( + variant: .updateProfilePicture, + behaviour: .recurringOnActive + ).inserted(db) + + _ = try Job( + variant: .retrieveDefaultOpenGroupRooms, + behaviour: .recurringOnActive + ).inserted(db) + + _ = try Job( + variant: .garbageCollection, + behaviour: .recurringOnActive + ).inserted(db) + } + + 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 new file mode 100644 index 000000000..e1c8d8b4e --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -0,0 +1,1851 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import AVKit +import GRDB +import YapDatabase +import Curve25519Kit +import SessionUtilitiesKit +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 needsConfigSync: Bool = true + static let minExpectedRunDuration: TimeInterval = 20 + + 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 + } + + // MARK: - Read from Legacy Database + + let timestampNow: TimeInterval = Date().timeIntervalSince1970 + var shouldFailMigration: Bool = false + var legacyMigrations: Set = [] + var contacts: Set = [] + var legacyBlockedSessionIds: Set = [] + var validProfileIds: Set = [] + var contactThreadIds: Set = [] + + var legacyThreadIdToIdMap: [String: String] = [:] + 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: SMKLegacy._GroupModel] = [:] + var closedGroupZombieMemberIds: [String: Set] = [:] + + var openGroupServer: [String: String] = [:] + var openGroupInfo: [String: SMKLegacy._OpenGroup] = [:] + var openGroupUserCount: [String: Int64] = [:] + var openGroupImage: [String: Data] = [:] + + var interactions: [String: [SMKLegacy._DBInteraction]] = [:] + var attachments: [String: SMKLegacy._Attachment] = [:] + var processedAttachmentIds: Set = [] + var outgoingReadReceiptsTimestampsMs: [String: Set] = [:] + var receivedMessageTimestamps: Set = [] + var receivedCallUUIDs: [String: Set] = [:] + + var notifyPushServerJobs: Set = [] + var messageReceiveJobs: Set = [] + var messageSendJobs: Set = [] + var attachmentUploadJobs: Set = [] + var attachmentDownloadJobs: Set = [] + + 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.enumerateKeys(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) + } + Storage.update(progress: 0.01, for: self, in: target) + + // MARK: --Contacts + + SNLog("[Migration Info] \(target.key(with: self)) - Processing Contacts") + + 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) + } + + legacyBlockedSessionIds = Set(transaction.object( + forKey: SMKLegacy.blockedPhoneNumbersKey, + inCollection: SMKLegacy.blockListCollection + ) as? [String] ?? []) + Storage.update(progress: 0.02, for: self, in: target) + + // MARK: --Threads + + SNLog("[Migration Info] \(target.key(with: self)) - Processing Threads") + + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.threadCollection) { key, object, _ in + guard let thread: SMKLegacy._Thread = object as? SMKLegacy._Thread else { return } + + 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: SMKLegacy.contactThreadPrefix) && thread.shouldBeVisible { + contactThreadIds.insert(key) + } + + // Get the disappearing messages config + disappearingMessagesConfiguration[thread.uniqueId] = transaction + .object(forKey: thread.uniqueId, inCollection: SMKLegacy.disappearingMessagesCollection) + .asType(SMKLegacy._DisappearingMessagesConfiguration.self) + + // Process group-specific info + guard let groupThread: SMKLegacy._GroupThread = thread as? SMKLegacy._GroupThread else { + legacyThreadIdToIdMap[thread.uniqueId] = thread.uniqueId.substring( + from: SMKLegacy.contactThreadPrefix.count + ) + 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(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), + let publicKey: String = groupId.split(separator: "!").last.map({ String($0) }) + else { + SNLog("[Migration Error] Unable to decode Closed Group") + shouldFailMigration = true + return + } + + 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: 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 = "\(SMKLegacy.closedGroupKeyPairPrefix)\(publicKey)" + + transaction.enumerateKeysAndObjects(inCollection: keyCollection) { key, object, _ in + guard + let timestamp: TimeInterval = TimeInterval(key), + let keyPair: SUKLegacy.KeyPair = object as? SUKLegacy.KeyPair + else { return } + + closedGroupKeys[thread.uniqueId] = (closedGroupKeys[thread.uniqueId] ?? [:]) + .setting(timestamp, keyPair) + } + } + else if groupThread.isOpenGroup { + 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 + } + + // 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 OpenGroupManager.isSessionRunOpenGroup(server: openGroup.server) else { + return openGroup.server + } + + return OpenGroupAPI.defaultServer + }() + legacyThreadIdToIdMap[thread.uniqueId] = OpenGroup.idFor( + roomToken: openGroup.room, + 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 + } + } + Storage.update(progress: 0.04, for: self, in: target) + + // MARK: --Interactions + + 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") + shouldFailMigration = true + 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) + + rowIndex += 1 + + Storage.update( + progress: min( + interactionsCompleteProgress, + ((rowIndex / roughNumRows) * (interactionsCompleteProgress - startProgress)) + ), + for: self, + in: target + ) + } + Storage.update(progress: interactionsCompleteProgress, for: self, in: target) + + // MARK: --Attachments + + SNLog("[Migration Info] \(target.key(with: self)) - Processing Attachments") + + 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 + } + + attachments[key] = attachment + } + Storage.update(progress: 0.21, for: self, in: target) + + // MARK: --Read Receipts + + transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.outgoingReadReceiptManagerCollection) { key, object, _ in + guard let timestampsMs: Set = object as? Set else { return } + + outgoingReadReceiptsTimestampsMs[key] = (outgoingReadReceiptsTimestampsMs[key] ?? Set()) + .union(timestampsMs) + } + + // MARK: --De-duping + + receivedMessageTimestamps = receivedMessageTimestamps.inserting( + contentsOf: transaction + .object( + forKey: SMKLegacy.receivedMessageTimestampsKey, + inCollection: SMKLegacy.receivedMessageTimestampsCollection + ) + .asType([UInt64].self) + .defaulting(to: []) + .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") + + 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) + } + Storage.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 + 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 + .object( + forKey: SMKLegacy.readReceiptManagerAreReadReceiptsEnabled, + inCollection: SMKLegacy.readReceiptManagerCollection + ) + .asType(NSNumber.self)? + .boolValue) + .defaulting(to: false) + + legacyPreferences[SMKLegacy.typingIndicatorsEnabledKey] = (transaction + .object( + forKey: SMKLegacy.typingIndicatorsEnabledKey, + inCollection: SMKLegacy.typingIndicatorsCollection + ) + .asType(NSNumber.self)? + .boolValue) + .defaulting(to: false) + + legacyPreferences[SMKLegacy.screenLockIsScreenLockEnabledKey] = (transaction + .object( + forKey: SMKLegacy.screenLockIsScreenLockEnabledKey, + inCollection: SMKLegacy.screenLockCollection + ) + .asType(NSNumber.self)? + .boolValue) + .defaulting(to: false) + + legacyPreferences[SMKLegacy.screenLockScreenLockTimeoutSecondsKey] = (transaction + .object( + forKey: SMKLegacy.screenLockScreenLockTimeoutSecondsKey, + inCollection: SMKLegacy.screenLockCollection) + .asType(NSNumber.self)? + .doubleValue) + .defaulting(to: (15 * 60)) + Storage.update(progress: 0.23, for: self, in: target) + } + + // We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here + guard !shouldFailMigration else { throw StorageError.migrationFailed } + + // Insert the data into GRDB + + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + + // MARK: - Insert Contacts + + SNLog("[Migration Info] \(target.key(with: self)) - Inserting Contacts") + + try autoreleasepool { + // Values for contact progress + let contactStartProgress: CGFloat = 0.23 + let progressPerContact: CGFloat = (0.05 / CGFloat(contacts.count)) + + try contacts.enumerated().forEach { index, legacyContact in + let isCurrentUser: Bool = (legacyContact.sessionID == currentUserPublicKey) + let contactThreadId: String = SMKLegacy._ContactThread.threadId(from: legacyContact.sessionID) + + // Create the "Profile" for the legacy contact + try Profile( + id: legacyContact.sessionID, + name: (legacyContact.name ?? legacyContact.sessionID), + nickname: legacyContact.nickname, + profilePictureUrl: legacyContact.profilePictureURL, + profilePictureFileName: legacyContact.profilePictureFileName, + 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) + ) + + /// 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 + isCurrentUser || + contactThreadIds.contains(contactThreadId) || + legacyContact.isApproved || + legacyContact.didApproveMe || + legacyContact.isBlocked || + legacyContact.hasBeenBlocked || + shouldForceTrustContact || + shouldForceApproveContact || + shouldForceBlockContact || + shouldForceIsApproved || + shouldForceDidApproveMe + { + // Create the contact + try Contact( + id: legacyContact.sessionID, + isTrusted: ( + isCurrentUser || + legacyContact.isTrusted || + shouldForceTrustContact + ), + isApproved: ( + isCurrentUser || + legacyContact.isApproved || + shouldForceApproveContact || + shouldForceIsApproved + ), + isBlocked: ( + !isCurrentUser && ( + legacyContact.isBlocked || + shouldForceBlockContact + ) + ), + didApproveMe: ( + isCurrentUser || + legacyContact.didApproveMe || + shouldForceApproveContact || + shouldForceDidApproveMe + ), + hasBeenBlocked: (!isCurrentUser && (legacyContact.hasBeenBlocked || legacyContact.isBlocked)) + ).insert(db) + } + + // Increment the progress for each contact + Storage.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 + + SNLog("[Migration Info] \(target.key(with: self)) - Inserting Threads & Interactions") + + var legacyInteractionToIdMap: [String: Int64] = [:] + var legacyInteractionIdentifierToIdMap: [String: Int64] = [:] + var legacyInteractionIdentifierToIdFallbackMap: [String: Int64] = [:] + + 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 { + case .contact(let publicKey): return publicKey + default: break + } + } + + return (recipients.first ?? "0") + }() + + return [ + (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: "-") + } + + // 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 { + SNLog("[Migration Error] Unable to migrate thread with no id mapping") + throw StorageError.migrationFailed + } + + let threadVariant: SessionThread.Variant + let onlyNotifyForMentions: Bool + + switch legacyThread { + case let groupThread as SMKLegacy._GroupThread: + threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup) + onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions + + default: + threadVariant = .contact + onlyNotifyForMentions = false + } + + try autoreleasepool { + try SessionThread( + id: threadId, + variant: threadVariant, + creationDateTimestamp: legacyThread.creationDate.timeIntervalSince1970, + shouldBeVisible: legacyThread.shouldBeVisible, + isPinned: legacyThread.isPinned, + messageDraft: ((legacyThread.messageDraft ?? "").isEmpty ? + nil : + legacyThread.messageDraft + ), + mutedUntilTimestamp: legacyThread.mutedUntilDate?.timeIntervalSince1970, + onlyNotifyForMentions: onlyNotifyForMentions + ).insert(db) + + // Disappearing Messages Configuration + if let config: SMKLegacy._DisappearingMessagesConfiguration = disappearingMessagesConfiguration[threadId] { + try DisappearingMessagesConfiguration( + threadId: threadId, + isEnabled: config.isEnabled, + durationSeconds: TimeInterval(config.durationSeconds) + ).insert(db) + } + else { + try DisappearingMessagesConfiguration + .defaultWith(threadId) + .insert(db) + } + + // Closed Groups + if legacyThread.isClosedGroup { + guard + 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 StorageError.migrationFailed + } + + try ClosedGroup( + threadId: threadId, + name: name, + formationTimestamp: TimeInterval(formationTimestamp) + ).insert(db) + + // 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[legacyThread.uniqueId]?.forEach { timestamp, legacyKeys in + try ClosedGroupKeyPair( + threadId: threadId, + publicKey: legacyKeys.publicKey, + secretKey: legacyKeys.privateKey, + receivedTimestamp: timestamp + ).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) + 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 + try GroupMember( + groupId: threadId, + profileId: adminId, + role: .admin + ).insert(db) + + if !validProfileIds.contains(adminId) { + createDummyProfile(profileId: adminId) + } + } + + try (closedGroupZombieMemberIds[legacyThread.uniqueId] ?? []).forEach { zombieId in + try GroupMember( + groupId: threadId, + profileId: zombieId, + role: .zombie + ).insert(db) + + if !validProfileIds.contains(zombieId) { + createDummyProfile(profileId: zombieId) + } + } + } + + // Open Groups + if legacyThread.isOpenGroup { + 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: targetOpenGroupServer, + roomToken: openGroup.room, + publicKey: openGroup.publicKey, + isActive: true, + name: openGroup.name, + roomDescription: nil, + imageId: openGroup.imageID, + imageData: openGroupImage[legacyThread.uniqueId], + userCount: (openGroupUserCount[legacyThread.uniqueId] ?? 0), // Will be updated next poll + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ).insert(db) + } + } + + try autoreleasepool { + try interactions[legacyThread.uniqueId]? + .sorted(by: { lhs, rhs in lhs.timestamp < rhs.timestamp }) // Maintain sort order + .forEach { legacyInteraction in + let serverHash: String? + 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: SMKLegacy._DBOutgoingMessageRecipientState]? + let mostRecentFailureText: String? + let quotedMessage: SMKLegacy._DBQuotedMessage? + let linkPreview: SMKLegacy._DBLinkPreview? + let linkPreviewVariant: LinkPreview.Variant + var attachmentIds: [String] + + // 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 + // 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 + ) + quotedMessage = legacyMessage.quotedMessage + + // Convert the 'OpenGroupInvitation' into a LinkPreview + if let openGroupInvitationName: String = legacyMessage.openGroupInvitationName, let openGroupInvitationUrl: String = legacyMessage.openGroupInvitationURL { + linkPreviewVariant = .openGroupInvitation + linkPreview = SMKLegacy._DBLinkPreview( + urlString: openGroupInvitationUrl, + title: openGroupInvitationName, + imageAttachmentId: nil + ) + } + else { + linkPreviewVariant = .standard + linkPreview = legacyMessage.linkPreview + } + + // Attachments for deleted messages won't exist + attachmentIds = (legacyMessage.isDeleted ? + [] : + legacyMessage.attachmentIds + ) + } + else { + serverHash = nil + openGroupServerMessageId = nil + quotedMessage = nil + linkPreviewVariant = .standard + linkPreview = nil + attachmentIds = [] + } + + // Then handle the behaviours for each message type + switch legacyInteraction { + case let incomingMessage as SMKLegacy._DBIncomingMessage: + // Note: We want to distinguish deleted messages from normal ones + variant = (incomingMessage.isDeleted ? + .standardIncomingDeleted : + .standardIncoming + ) + authorId = incomingMessage.authorId + body = incomingMessage.body + wasRead = incomingMessage.wasRead + expiresInSeconds = incomingMessage.expiresInSeconds + expiresStartedAtMs = incomingMessage.expireStartedAt + recipientStateMap = [:] + mostRecentFailureText = nil + + case let outgoingMessage as SMKLegacy._DBOutgoingMessage: + 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 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 + body = { + // 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 + } + }() + ) + + guard + let messageInfoData: Data = try? JSONEncoder().encode(messageInfo), + let messageInfoDataString: String = String(data: messageInfoData, encoding: .utf8) + else { break } + + return messageInfoDataString + + default: break + } + + return ((infoMessage.body ?? "").isEmpty ? + 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 + case .groupUpdated: variant = .infoClosedGroupUpdated + case .groupCurrentUserLeft: variant = .infoClosedGroupCurrentUserLeft + case .disappearingMessagesUpdate: variant = .infoDisappearingMessagesUpdate + case .screenshotNotification: variant = .infoScreenshotNotification + case .mediaSavedNotification: variant = .infoMediaSavedNotification + case .call: variant = .infoCall + case .messageRequestAccepted: variant = .infoMessageRequestAccepted + } + + default: + SNLog("[Migration Error] Unsupported interaction type") + throw StorageError.migrationFailed + } + + // Insert the data + 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: 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 ? + 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( + 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 { + SNLog("[Migration Error] Failed to insert interaction") + throw StorageError.migrationFailed + } + + // Store the interactionId in the lookup map to simplify job creation later + let legacyIdentifier: String = identifier( + for: threadId, + sentTimestamp: legacyInteraction.timestamp, + recipients: ((legacyInteraction as? SMKLegacy._DBOutgoingMessage)? + .recipientStateMap? + .keys + .map { $0 }) + .defaulting(to: []), + destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil), + variant: variant, + useFallback: false + ) + let legacyIdentifierFallback: String = identifier( + for: threadId, + sentTimestamp: legacyInteraction.timestamp, + 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 + legacyInteractionIdentifierToIdMap[legacyIdentifier] = interactionId + legacyInteractionIdentifierToIdFallbackMap[legacyIdentifierFallback] = 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, + recipientId: recipientId, + state: { + switch legacyState.state { + case .failed: return .failed + case .sending: return .sending + case .skipped: return .skipped + case .sent: return .sent + } + }(), + readTimestampMs: legacyState.readTimestamp, + mostRecentFailureText: (legacyState.state == .failed ? + mostRecentFailureText : + nil + ) + ).save(db) + } + + // Handle any quote + + if let quotedMessage: SMKLegacy._DBQuotedMessage = quotedMessage { + 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 { attachmentId -> Bool in attachments[attachmentId] != nil } + + // 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[legacyThread.uniqueId]? + .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? SMKLegacy._DBIncomingMessage)?.authorId + ) + }) + .asType(SMKLegacy._DBMessage.self)? + .attachmentIds + .first + + 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, + isQuotedMessage: true, + attachments: attachments, + processedAttachmentIds: &processedAttachmentIds + ) + + // Create the quote + try Quote( + interactionId: interactionId, + authorId: quotedMessage.authorId, + timestampMs: Int64(quotedMessage.timestamp), + body: quotedMessage.body, + attachmentId: attachmentId + ).insert(db) + } + + // Handle any LinkPreview + + 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)) + + // 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, + attachments: attachments, + processedAttachmentIds: &processedAttachmentIds + ) + + // 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, + attachmentId: attachmentId + ).save(db) + } + + // Handle any attachments + + try attachmentIds.enumerated().forEach { index, legacyAttachmentId in + let maybeAttachmentId: String? = (try attachmentId( + db, + for: legacyAttachmentId, + interactionVariant: variant, + attachments: attachments, + processedAttachmentIds: &processedAttachmentIds + )) + .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 + } + + // Link the attachment to the interaction and add to the id lookup + try InteractionAttachment( + albumIndex: index, + interactionId: interactionId, + attachmentId: attachmentId + ).insert(db) + } + + // Increment the progress for each contact + Storage.update( + progress: ( + threadInteractionsStartProgress + + (progressPerInteraction * (interactionCounter + 1)) + ), + for: self, + in: target + ) + interactionCounter += 1 + } + } + } + + // Clear out processed data (give the memory a change to be freed) + legacyThreads = [] + disappearingMessagesConfiguration = [:] + + closedGroupKeys = [:] + closedGroupName = [:] + closedGroupFormation = [:] + closedGroupModel = [:] + closedGroupZombieMemberIds = [:] + + openGroupInfo = [:] + openGroupUserCount = [:] + openGroupImage = [:] + + 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: - Insert Jobs + + SNLog("[Migration Info] \(target.key(with: self)) - Inserting Jobs") + + // MARK: --notifyPushServer + + try autoreleasepool { + try notifyPushServerJobs.forEach { legacyJob in + _ = try Job( + failureCount: legacyJob.failureCount, + variant: .notifyPushServer, + behaviour: .runOnce, + nextRunTimestamp: 0, + 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) + } + } + + // 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 + } + + // 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 + } + + _ = try Job( + failureCount: legacyJob.failureCount, + variant: .messageReceive, + behaviour: .runOnce, + nextRunTimestamp: 0, + threadId: processedMessage.threadId, + details: MessageReceiveJob.Details( + messages: [processedMessage.messageInfo], + isBackgroundPoll: legacyJob.isBackgroundPoll + ) + )?.inserted(db) + } + } + + // MARK: --messageSend + + var messageSendJobLegacyMap: [String: Job] = [:] + + try autoreleasepool { + try messageSendJobs.forEach { legacyJob in + // 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 .openGroup(let roomToken, let server, _, _, _): + return OpenGroup.idFor(roomToken: roomToken, server: server) + + case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey + } + }() + 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 SMKLegacy._ExpirationTimerUpdate: + return .infoDisappearingMessagesUpdate + default: return nil + } + }(), + useFallback: true + ) + + 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, + 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( + destination: legacyJob.destination, + message: legacyJob.message.toNonLegacy() + ) + )?.inserted(db) + + if let oldId: String = legacyJob.id { + messageSendJobLegacyMap[oldId] = job + } + } + } + + // MARK: --attachmentUpload + + try autoreleasepool { + 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 StorageError.migrationFailed + } + + let uploadJob: Job? = try Job( + failureCount: legacyJob.failureCount, + variant: .attachmentUpload, + behaviour: .runOnce, + threadId: sendJob.threadId, + interactionId: sendJob.interactionId, + details: AttachmentUploadJob.Details( + 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 StorageError.migrationFailed + } + + try JobDependencies( + jobId: sendJobId, + dependantId: uploadJobId + ).insert(db) + } + } + + // MARK: --attachmentDownload + + 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 + } + guard processedAttachmentIds.contains(legacyJob.attachmentID) else { + // 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( + failureCount: legacyJob.failureCount, + variant: .attachmentDownload, + behaviour: .runOnce, + nextRunTimestamp: 0, + threadId: legacyThreadIdToIdMap[legacyJob.threadID], + interactionId: interactionId, + details: AttachmentDownloadJob.Details( + attachmentId: legacyJob.attachmentID + ) + )?.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) + } + } + Storage.update(progress: 0.99, for: self, in: target) + + // MARK: - Preferences + + 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) + 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[SMKLegacy.preferencesKeyLastRecordedPushToken] as? String { + db[.lastRecordedPushToken] = lastPushToken + } + + 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[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[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) + + // 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) + + // 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 + } + + // MARK: - Convenience + + private static func attachmentId( + _ db: Database, + for legacyAttachmentId: String?, + interactionVariant: Interaction.Variant? = nil, + isQuotedMessage: Bool = false, + attachments: [String: SMKLegacy._Attachment], + processedAttachmentIds: inout Set + ) throws -> String? { + guard let legacyAttachmentId: String = legacyAttachmentId else { return nil } + guard !processedAttachmentIds.contains(legacyAttachmentId) else { + guard isQuotedMessage else { + SNLog("[Migration Error] Attempted to process duplicate attachment") + throw StorageError.migrationFailed + } + + return legacyAttachmentId + } + + guard let legacyAttachment: SMKLegacy._Attachment = attachments[legacyAttachmentId] else { + SNLog("[Migration Warning] Missing attachment - interaction will show a \"failed\" attachment") + return nil + } + + let processedLocalRelativeFilePath: String? = (legacyAttachment as? SMKLegacy._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 SMKLegacy._AttachmentStream: // Outgoing or already downloaded + switch interactionVariant { + case .standardOutgoing: return (stream.isUploaded ? .uploaded : .uploading) + default: return .downloaded + } + + // All other cases can just be set to 'pendingDownload' + default: return .pendingDownload + } + }() + let size: CGSize = { + switch legacyAttachment { + case let stream as SMKLegacy._AttachmentStream: + // 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 + } + + return Attachment + .imageSize( + contentType: stream.contentType, + originalFilePath: originalFilePath + ) + .defaulting(to: .zero) + + case let pointer as SMKLegacy._AttachmentPointer: return pointer.mediaSize + default: return CGSize.zero + } + }() + let (isValid, duration): (Bool, TimeInterval?) = { + guard + let stream: SMKLegacy._AttachmentStream = legacyAttachment as? SMKLegacy._AttachmentStream, + let originalFilePath: String = Attachment.originalFilePath( + id: legacyAttachmentId, + mimeType: stream.contentType, + sourceFilename: stream.sourceFilename + ) + else { + return (false, nil) + } + + if stream.isAudio { + if let cachedDuration: TimeInterval = stream.cachedAudioDurationSeconds?.doubleValue, cachedDuration > 0 { + return (true, cachedDuration) + } + + let attachmentVailidityInfo = Attachment.determineValidityAndDuration( + contentType: stream.contentType, + localRelativeFilePath: processedLocalRelativeFilePath, + originalFilePath: originalFilePath + ) + + return (attachmentVailidityInfo.isValid, attachmentVailidityInfo.duration) + } + + if stream.isVisualMedia { + let attachmentVailidityInfo = Attachment.determineValidityAndDuration( + contentType: stream.contentType, + localRelativeFilePath: processedLocalRelativeFilePath, + originalFilePath: originalFilePath + ) + + return (attachmentVailidityInfo.isValid, attachmentVailidityInfo.duration) + } + + 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 + // need to bother renaming files as part of the migration + id: legacyAttachmentId, + serverId: "\(legacyAttachment.serverId)", + variant: (legacyAttachment.attachmentType == .voiceMessage ? .voiceMessage : .standard), + state: state, + contentType: legacyAttachment.contentType, + byteCount: UInt(legacyAttachment.byteCount), + creationTimestamp: (legacyAttachment as? SMKLegacy._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, + isValid: isValid, + encryptionKey: legacyAttachment.encryptionKey, + 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) + + processedAttachmentIds.insert(legacyAttachmentId) + + 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, + 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._OpenGroup.self, + forClassName: "SNOpenGroupV2" + ) + 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._DataExtractionNotificationInfoMessage.self, + forClassName: "SNDataExtractionNotificationInfoMessage" + ) + 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/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/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift new file mode 100644 index 000000000..a62849bd7 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -0,0 +1,1142 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SignalCoreKit +import SessionUtilitiesKit +import AVFAudio +import AVFoundation + +public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "attachment" } + 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, + using: InteractionAttachment.interaction + ) + fileprivate static let quote = belongsTo(Quote.self, using: quoteForeignKey) + fileprivate static let linkPreview = belongsTo(LinkPreview.self, using: linkPreviewForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case id + case serverId + case variant + case state + case contentType + case byteCount + case creationTimestamp + case sourceFilename + case downloadUrl + case localRelativeFilePath + case width + case height + case duration + case isVisualMedia + case isValid + case encryptionKey + case digest + case caption + } + + public enum Variant: Int, Codable, DatabaseValueConvertible { + case standard + case voiceMessage + } + + public enum State: Int, Codable, DatabaseValueConvertible { + case failedDownload + case pendingDownload + case downloading + case downloaded + case failedUpload + case uploading + case uploaded + + case invalid = 100 + } + + /// A unique identifier for the attachment + public let id: String + + /// 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 + /// **Other:** This will be null + 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 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? + + /// 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 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 + + /// 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? + + // MARK: - Initialization + + public init( + id: String = UUID().uuidString, + serverId: String? = nil, + variant: Variant, + state: State = .pendingDownload, + contentType: String, + byteCount: UInt, + creationTimestamp: TimeInterval? = nil, + sourceFilename: String? = nil, + downloadUrl: String? = nil, + localRelativeFilePath: String? = nil, + width: UInt? = nil, + height: UInt? = nil, + duration: TimeInterval? = nil, + isVisualMedia: Bool? = 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 + self.contentType = contentType + self.byteCount = byteCount + self.creationTimestamp = creationTimestamp + self.sourceFilename = sourceFilename + self.downloadUrl = downloadUrl + self.localRelativeFilePath = localRelativeFilePath + 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 + 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, + sourceFilename: String? = nil, + caption: String? = nil + ) { + guard let originalFilePath: String = Attachment.originalFilePath(id: id, mimeType: contentType, sourceFilename: sourceFilename) else { + return nil + } + guard dataSource.write(toPath: originalFilePath) else { return nil } + + let imageSize: CGSize? = Attachment.imageSize( + contentType: contentType, + originalFilePath: originalFilePath + ) + let (isValid, duration): (Bool, TimeInterval?) = Attachment.determineValidityAndDuration( + contentType: contentType, + localRelativeFilePath: nil, + originalFilePath: originalFilePath + ) + + self.id = id + self.serverId = nil + self.variant = variant + self.state = .uploading + self.contentType = contentType + self.byteCount = dataSource.dataLength() + self.creationTimestamp = nil + self.sourceFilename = sourceFilename + self.downloadUrl = nil + 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 + self.isVisualMedia = ( + MIMETypeUtil.isImage(contentType) || + MIMETypeUtil.isVideo(contentType) || + MIMETypeUtil.isAnimated(contentType) + ) + self.isValid = isValid + self.encryptionKey = nil + self.digest = nil + self.caption = caption + } +} + +// MARK: - CustomStringConvertible + +extension Attachment: CustomStringConvertible { + 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 + } + } + + 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 "\(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. + if + descriptionInfo.variant == .voiceMessage || + descriptionInfo.sourceFilename == nil || + (descriptionInfo.sourceFilename?.count ?? 0) == 0 + { + return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())" + } + } + + return "\(emoji(for: descriptionInfo.contentType)) \("ATTACHMENT".localized())" + } + + 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: DescriptionInfo( + id: id, + variant: variant, + contentType: contentType, + sourceFilename: sourceFilename + ), + count: 1 + ) + } +} + +// MARK: - Mutation + +extension Attachment { + public func with( + serverId: String? = nil, + state: State? = nil, + creationTimestamp: TimeInterval? = nil, + downloadUrl: String? = nil, + localRelativeFilePath: String? = nil, + encryptionKey: Data? = nil, + digest: Data? = nil + ) -> Attachment { + let (isValid, duration): (Bool, TimeInterval?) = { + switch (self.state, state) { + case (_, .downloaded): + return Attachment.determineValidityAndDuration( + contentType: contentType, + localRelativeFilePath: localRelativeFilePath, + originalFilePath: originalFilePath + ) + + // Assume the data is already correct for "uploading" attachments (and don't override it) + case (.uploading, _), (.uploaded, _), (.failedUpload, _): return (self.isValid, self.duration) + case (_, .failedDownload): return (false, nil) + + 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, + serverId: (serverId ?? self.serverId), + variant: variant, + state: (state ?? self.state), + contentType: contentType, + byteCount: byteCount, + creationTimestamp: (creationTimestamp ?? self.creationTimestamp), + sourceFilename: sourceFilename, + downloadUrl: (downloadUrl ?? self.downloadUrl), + localRelativeFilePath: (localRelativeFilePath ?? self.localRelativeFilePath), + width: attachmentResolution.map { UInt($0.width) }, + height: attachmentResolution.map { UInt($0.height) }, + duration: duration, + 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), + caption: self.caption + ) + } +} + +// MARK: - Protobuf + +extension Attachment { + public init(proto: SNProtoAttachmentPointer) { + func inferContentType(from filename: String?) -> String { + guard + let fileName: String = filename, + let fileExtension: String = URL(string: fileName)?.pathExtension + else { return OWSMimeTypeApplicationOctetStream } + + return (MIMETypeUtil.mimeType(forFileExtension: fileExtension) ?? OWSMimeTypeApplicationOctetStream) + } + + self.id = UUID().uuidString + self.serverId = "\(proto.id)" + self.variant = { + let voiceMessageFlag: Int32 = SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags + .voiceMessage + .rawValue + + guard proto.hasFlags && ((proto.flags & UInt32(voiceMessageFlag)) > 0) else { + return .standard + } + + return .voiceMessage + }() + self.state = .pendingDownload + 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.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.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 + self.caption = (proto.hasCaption ? proto.caption : nil) + } + + public 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 + +extension Attachment { + public struct StateInfo: FetchableRecord, Decodable { + 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 { + 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, + \(attachment[.downloadUrl]) AS downloadUrl + + FROM \(Attachment.self) + + JOIN \(Interaction.self) ON + \(SQL("\(interaction[.authorId]) = \(authorId)")) 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)")) + ) + + ORDER BY interactionId DESC + """ + } + + public 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, + \(attachment[.downloadUrl]) AS downloadUrl + + 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 + +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 thumbnailDimensionLarge: UInt = { + let screenSizePoints: CGSize = UIScreen.main.bounds.size + let minZoomFactor: CGFloat = UIScreen.main.scale + + return UInt(floor(max(screenSizePoints.width, screenSizePoints.height) * minZoomFactor)) + }() + + private static var sharedDataAttachmentsDirPath: String = { + URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) + .appendingPathComponent("Attachments") + .path + }() + + internal static var attachmentsFolder: 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( + forAttachment: id, + ofMIMEType: mimeType, + sourceFilename: sourceFilename, + inFolder: Attachment.attachmentsFolder + ) + } + + 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) + 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) + } + + public static func videoStillImage(filePath: String) -> UIImage? { + return try? OWSMediaUtils.thumbnail( + forVideoAtPath: filePath, + maxDimension: CGFloat(Attachment.thumbnailDimensionLarge) + ) + } + + internal static func determineValidityAndDuration( + contentType: String, + localRelativeFilePath: String?, + originalFilePath: String? + ) -> (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: targetPath)) + + 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) + + default: return (false, nil) + } + } + } + + // Process image attachments + if MIMETypeUtil.isImage(contentType) || MIMETypeUtil.isAnimated(contentType) { + return ( + NSData.ows_isValidImage(atPath: targetPath, mimeType: contentType), + nil + ) + } + + // Process video attachments + if MIMETypeUtil.isVideo(contentType) { + 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), + durationSeconds + ) + } + + // Any other attachment types are valid and have no duration + return (true, nil) + } +} + +// MARK: - Convenience + +extension Attachment { + public static let nonMediaQuoteFileId: String = "NON_MEDIA_QUOTE_FILE_ID" + + 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? { + if let localRelativeFilePath: String = self.localRelativeFilePath { + return URL(fileURLWithPath: Attachment.attachmentsFolder) + .appendingPathComponent(localRelativeFilePath) + .path + } + + return Attachment.originalFilePath( + id: self.id, + mimeType: self.contentType, + sourceFilename: self.sourceFilename + ) + } + + var thumbnailsDirPath: String { + // Thumbnails are written to the caches directory, so that iOS can + // remove them if necessary + 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().path + + return "\(containingDir)/\(filename)-signal-ios-thumbnail.jpg" + } + + 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 isValid else { return nil } + + return UIImage(contentsOfFile: originalFilePath) + } + + 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) } + public var isText: Bool { MIMETypeUtil.isText(contentType) } + public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) } + + public func readDataFromFile() throws -> Data? { + guard let filePath: String = self.originalFilePath 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, () throws -> Data) -> (), 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, + { + 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 data: Data = try? Data(contentsOf: URL(fileURLWithPath: thumbnailPath)), + let image: UIImage = UIImage(data: data) + else { + failure() + return + } + + success(image, { data }) + return + } + + ThumbnailService.shared.ensureThumbnail( + for: self, + dimensions: dimensions, + success: { loadedThumbnail in success(loadedThumbnail.image, loadedThumbnail.dataSourceBlock) }, + failure: { _ in failure() } + ) + } + + public func thumbnail(size: ThumbnailSize, success: @escaping (UIImage, () throws -> Data) -> (), failure: @escaping () -> ()) { + 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 cloneAsQuoteThumbnail() -> 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 { + // 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? + 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, + state: .downloaded, + contentType: OWSMimeTypeImageJpeg, + byteCount: UInt(thumbnailData.count), + sourceFilename: thumbnailName, + localRelativeFilePath: Attachment.localRelativeFilePath(from: thumbnailPath), + 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 + } + + public static func fileId(for downloadUrl: String?) -> String? { + return downloadUrl + .map { urlString -> String? in + urlString + .split(separator: "/") + .last + .map { String($0) } + } + } +} + +// MARK: - Upload + +extension Attachment { + internal func upload( + _ db: Database? = nil, + queue: DispatchQueue, + using upload: (Database, Data) -> Promise, + encrypt: Bool, + 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?(Attachment.fileId(for: self.downloadUrl)) + return + } + + // Get the attachment + guard var data = try? readDataFromFile() else { + SNLog("Couldn't read attachment from disk.") + failure?(AttachmentError.noAttachment) + 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 + // 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? = { + guard let db: Database = db else { + Storage.shared.write { db in + try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) + } + + return self.with(state: .uploaded) + } + + _ = try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) + + return self.with(state: .uploaded) + }() + + guard uploadedAttachment != nil else { + SNLog("Couldn't update attachmentUpload job.") + failure?(StorageError.failedToSave) + return + } + + success?(Attachment.fileId(for: self.downloadUrl)) + 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?(AttachmentError.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(FileServerAPI.maxFileSize) / FileServerAPI.fileSizeORMultiplier { + failure?(HTTP.Error.maxFileSizeExceeded) + return + } + + // Update the attachment to the 'uploading' state + let updatedAttachment: Attachment? = { + guard let db: Database = db else { + Storage.shared.write { db in + try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) + } + + return processedAttachment.with(state: .uploading) + } + + _ = try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) + + return processedAttachment.with(state: .uploading) + }() + + guard updatedAttachment != nil else { + SNLog("Couldn't update attachmentUpload job.") + failure?(StorageError.failedToSave) + return + } + + // Perform the upload + let uploadPromise: Promise = { + guard let db: Database = db else { + return Storage.shared.read { db in upload(db, data) } + } + + return upload(db, data) + }() + + uploadPromise + .done(on: queue) { fileId in + /// 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( + serverId: "\(fileId)", + state: .uploaded, + creationTimestamp: ( + updatedAttachment?.creationTimestamp ?? + Date().timeIntervalSince1970 + ), + downloadUrl: "\(FileServerAPI.server)/files/\(fileId)" + ) + .saved(db) + } + + guard uploadedAttachment != nil else { + SNLog("Couldn't update attachmentUpload job.") + failure?(StorageError.failedToSave) + return + } + + success?(fileId) + } + .catch(on: queue) { error in + Storage.shared.write { db in + try Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + } + + failure?(error) + } + } +} diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift new file mode 100644 index 000000000..d5e8704c6 --- /dev/null +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -0,0 +1,174 @@ +// 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, + sessionId: String? = nil, + openGroupServer: String, + openGroupPublicKey: String, + isCheckingForOutbox: Bool, + dependencies: SMKDependencies = SMKDependencies() + ) 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 } + + // 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 + let contactsThatApprovedMeCursor: RecordCursor = try Contact + .filter(Contact.Columns.didApproveMe == true) + .fetchCursor(db) + + while let contact: Contact = try contactsThatApprovedMeCursor.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) + + // 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 + } + + // 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) - 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()) + .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 new file mode 100644 index 000000000..9feda3eb1 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Capability.swift @@ -0,0 +1,95 @@ +// 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 openGroupServer + case variant + case isMissing + } + + 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: - Initialization + + public init( + openGroupServer: String, + variant: Variant, + isMissing: Bool + ) { + self.openGroupServer = openGroupServer + self.variant = variant + 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/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift new file mode 100644 index 000000000..48be3511a --- /dev/null +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -0,0 +1,89 @@ +// 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" } + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + internal static let keyPairs = hasMany( + ClosedGroupKeyPair.self, + using: ClosedGroupKeyPair.closedGroupForeignKey + ) + public static let members = hasMany(GroupMember.self, using: GroupMember.closedGroupForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId + case name + case formationTimestamp + } + + public var id: String { threadId } // Identifiable + public var publicKey: String { threadId } + + /// 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) + } + + 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 zombies: QueryInterfaceRequest { + request(for: ClosedGroup.members) + .filter(GroupMember.Columns.role == GroupMember.Role.zombie) + } + + public var moderators: QueryInterfaceRequest { + request(for: ClosedGroup.members) + .filter(GroupMember.Columns.role == GroupMember.Role.moderator) + } + + public var admins: QueryInterfaceRequest { + request(for: ClosedGroup.members) + .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: - 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 new file mode 100644 index 000000000..509fa0c9e --- /dev/null +++ b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift @@ -0,0 +1,58 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "closedGroupKeyPair" } + internal static let closedGroupForeignKey = ForeignKey( + [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 threadId: String + public let publicKey: Data + public let secretKey: Data + public let receivedTimestamp: TimeInterval + + // MARK: - Relationships + + public var closedGroup: QueryInterfaceRequest { + request(for: ClosedGroupKeyPair.closedGroup) + } + + // MARK: - Initialization + + public init( + threadId: String, + publicKey: Data, + secretKey: Data, + receivedTimestamp: TimeInterval + ) { + self.threadId = threadId + self.publicKey = publicKey + self.secretKey = secretKey + self.receivedTimestamp = receivedTimestamp + } +} + +// MARK: - GRDB Interactions + +public extension ClosedGroupKeyPair { + static func fetchLatestKeyPair(_ db: Database, threadId: String) throws -> ClosedGroupKeyPair? { + return try ClosedGroupKeyPair + .filter(Columns.threadId == threadId) + .order(Columns.receivedTimestamp.desc) + .fetchOne(db) + } +} diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift new file mode 100644 index 000000000..ab85bb808 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -0,0 +1,121 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +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) + + 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 let isTrusted: Bool + + /// This flag is used to determine whether message requests from this contact are approved + public let isApproved: Bool + + /// This flag is used to determine whether message requests from this contact are blocked + 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: - Relationships + + public var profile: QueryInterfaceRequest { + request(for: Contact.profile) + } + + // 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: - 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 { + /// 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)) + } +} + +// MARK: - Objective-C Support + +// TODO: Remove this when possible +@objc(SMKContact) +public class SMKContact: NSObject { + @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) + } + .defaulting(to: false) + } +} diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift new file mode 100644 index 000000000..b2efb6b98 --- /dev/null +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -0,0 +1,208 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 timestampMs + case variant + case serverExpirationTimestamp + } + + 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 + case call = 9 + } + + /// 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? + ) { + // 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 } + + // 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 } + + // 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 + // 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 + case is CallMessage: return .call + 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 + + case .infoCall: + self.variant = .call + } + + 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 new file mode 100644 index 000000000..6d4b1cfbc --- /dev/null +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -0,0 +1,224 @@ +// 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" } + 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 isEnabled + case durationSeconds + } + + public var id: String { threadId } // Identifiable + + public let threadId: String + public let isEnabled: Bool + public let durationSeconds: TimeInterval + + // MARK: - Relationships + + public var thread: QueryInterfaceRequest { + request(for: DisappearingMessagesConfiguration.thread) + } +} + +// 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 + +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 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(), + senderName, + NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false) + ) + } + } + + var durationString: String { + NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false) + } + + 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) + } +} + +// 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 + +// 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 Storage.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 = Storage.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] + ) + + Storage.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) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) + .with( + isEnabled: isEnabled, + durationSeconds: durationSeconds + ) + .saved(db) + + 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(isEnabled ? durationSeconds : 0)) + ), + interactionId: interaction.id, + in: thread + ) + } + } +} diff --git a/SessionMessagingKit/Database/Models/GroupMember.swift b/SessionMessagingKit/Database/Models/GroupMember.swift new file mode 100644 index 000000000..a59bfc417 --- /dev/null +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -0,0 +1,94 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +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]) + 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: Profile.groupMemberForeignKey) + + 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 + + // 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) + } + + // MARK: - Initialization + + public init( + groupId: String, + profileId: String, + role: Role + ) { + self.groupId = groupId + self.profileId = profileId + 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 Storage.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 Storage.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 new file mode 100644 index 000000000..238816699 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -0,0 +1,810 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +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 linkPreviewForeignKey = ForeignKey( + [Columns.linkPreviewUrl], + to: [LinkPreview.Columns.url] + ) + public static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + public static let profile = hasOne(Profile.self, using: Profile.interactionForeignKey) + public static let interactionAttachments = hasMany( + InteractionAttachment.self, + using: InteractionAttachment.interactionForeignKey + ) + public static let attachments = hasMany( + Attachment.self, + through: interactionAttachments, + using: InteractionAttachment.attachment + ) + 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 + public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) + public static func linkPreviewFilterLiteral( + timestampColumn: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + ) -> SQL { + let linkPreview: TypedTableAlias = TypedTableAlias() + + 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 + public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case id + case serverHash + case messageUuid + case threadId + case authorId + + case variant + case body + case timestampMs + case receivedAtTimestampMs + case wasRead + case hasMention + + case expiresInSeconds + case expiresStartedAtMs + case linkPreviewUrl + + // Open Group specific properties + + case openGroupServerMessageId + case openGroupWhisperMods + case openGroupWhisperTo + } + + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { + case standardIncoming + case standardOutgoing + case standardIncomingDeleted + + // 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 + + case infoCall = 5000 + + // MARK: - Convenience + + public var isInfoMessage: Bool { + switch self { + case .infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft, + .infoDisappearingMessagesUpdate, .infoScreenshotNotification, .infoMediaSavedNotification, + .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 private(set) var id: Int64? = nil + + /// The hash returned by the server when this message was created on the server + /// + /// **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 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 + + /// 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 + public let variant: Variant + + /// The body of this interaction + public let body: String? + + /// When the interaction was created in milliseconds since epoch + /// + /// **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 + /// + /// **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 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 + + /// The number of seconds until this message should expire + 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 let expiresStartedAtMs: Double? + + /// 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 + + /// The `openGroupServerMessageId` value will only be set for messages from SOGS + public let openGroupServerMessageId: Int64? + + /// 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) + } + + /// 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 { + let interactionAttachment: TypedTableAlias = TypedTableAlias() + + return request(for: Interaction.attachments) + .order(interactionAttachment[.albumIndex]) + } + + public var quote: QueryInterfaceRequest { + request(for: Interaction.quote) + } + + 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) + } + + public var recipientStates: QueryInterfaceRequest { + request(for: Interaction.recipientStates) + } + + // MARK: - Initialization + + internal init( + id: Int64? = nil, + serverHash: String?, + messageUuid: String?, + threadId: String, + authorId: String, + variant: Variant, + body: String?, + timestampMs: Int64, + receivedAtTimestampMs: Int64, + wasRead: Bool, + hasMention: Bool, + expiresInSeconds: TimeInterval?, + expiresStartedAtMs: Double?, + linkPreviewUrl: String?, + openGroupServerMessageId: Int64?, + openGroupWhisperMods: Bool, + openGroupWhisperTo: String? + ) { + self.id = id + self.serverHash = serverHash + self.messageUuid = messageUuid + self.threadId = threadId + self.authorId = authorId + self.variant = variant + self.body = body + self.timestampMs = timestampMs + self.receivedAtTimestampMs = receivedAtTimestampMs + self.wasRead = wasRead + self.hasMention = hasMention + self.expiresInSeconds = expiresInSeconds + self.expiresStartedAtMs = expiresStartedAtMs + self.linkPreviewUrl = linkPreviewUrl + self.openGroupServerMessageId = openGroupServerMessageId + self.openGroupWhisperMods = openGroupWhisperMods + self.openGroupWhisperTo = openGroupWhisperTo + } + + public init( + serverHash: String? = nil, + messageUuid: String? = nil, + threadId: String, + authorId: String, + variant: Variant, + body: String? = nil, + timestampMs: Int64 = 0, + wasRead: Bool = false, + hasMention: 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.messageUuid = messageUuid + 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.hasMention = hasMention + self.expiresInSeconds = expiresInSeconds + self.expiresStartedAtMs = expiresStartedAtMs + self.linkPreviewUrl = linkPreviewUrl + self.openGroupServerMessageId = openGroupServerMessageId + self.openGroupWhisperMods = openGroupWhisperMods + self.openGroupWhisperTo = openGroupWhisperTo + } + + // 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 + // 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 + } + + // 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 + // 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 + } + } +} + +// MARK: - Mutation + +public extension Interaction { + func with( + serverHash: String? = nil, + authorId: String? = nil, + body: String? = nil, + timestampMs: Int64? = nil, + wasRead: Bool? = nil, + hasMention: Bool? = nil, + expiresInSeconds: TimeInterval? = nil, + expiresStartedAtMs: Double? = nil, + openGroupServerMessageId: Int64? = nil + ) -> Interaction { + return Interaction( + id: self.id, + serverHash: (serverHash ?? self.serverHash), + messageUuid: self.messageUuid, + threadId: self.threadId, + authorId: (authorId ?? self.authorId), + variant: self.variant, + body: (body ?? self.body), + timestampMs: (timestampMs ?? self.timestampMs), + receivedAtTimestampMs: self.receivedAtTimestampMs, + wasRead: (wasRead ?? self.wasRead), + hasMention: (hasMention ?? self.hasMention), + expiresInSeconds: (expiresInSeconds ?? self.expiresInSeconds), + expiresStartedAtMs: (expiresStartedAtMs ?? self.expiresStartedAtMs), + linkPreviewUrl: self.linkPreviewUrl, + openGroupServerMessageId: (openGroupServerMessageId ?? self.openGroupServerMessageId), + openGroupWhisperMods: self.openGroupWhisperMods, + openGroupWhisperTo: self.openGroupWhisperTo + ) + } +} + +// MARK: - GRDB Interactions + +public extension Interaction { + /// 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` + 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: 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 + 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)) + + scheduleJobs(interactionIds: [interactionId]) + return + } + + let interactionQuery = Interaction + .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 + .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: interactionIdsToMarkAsRead) + } + + /// 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 } + + 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: - 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 { + static let oversizeTextMessageSizeThreshold: UInt = (2 * 1024) + + // MARK: - Variables + + var isExpiringMessage: Bool { + guard variant == .standardIncoming || variant == .standardOutgoing else { return false } + + return (expiresInSeconds ?? 0 > 0) + } + + var openGroupWhisper: Bool { return (openGroupWhisperMods || (openGroupWhisperTo != nil)) } + + var notificationIdentifiers: [String] { + [ + notificationIdentifier(isBackgroundPoll: true), + notificationIdentifier(isBackgroundPoll: false) + ] + } + + // MARK: - Functions + + 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, + messageUuid: messageUuid, + threadId: threadId, + authorId: authorId, + variant: .standardIncomingDeleted, + body: nil, + timestampMs: timestampMs, + receivedAtTimestampMs: receivedAtTimestampMs, + wasRead: wasRead, + hasMention: hasMention, + expiresInSeconds: expiresInSeconds, + expiresStartedAtMs: expiresStartedAtMs, + linkPreviewUrl: linkPreviewUrl, + openGroupServerMessageId: openGroupServerMessageId, + openGroupWhisperMods: openGroupWhisperMods, + openGroupWhisperTo: openGroupWhisperTo + ) + } + + static func isUserMentioned( + _ db: Database, + threadId: String, + body: String?, + quoteAuthorId: String? = nil + ) -> Bool { + var publicKeysToCheck: [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 + ) + } + } + + // 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("@\(publicKey)") + ) || ( + quoteAuthorId == publicKey + ) + } + } + + /// 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 .standardIncoming, .standardOutgoing: + return Interaction.previewText( + variant: self.variant, + body: self.body, + attachmentDescriptionInfo: try? attachments + .select(.id, .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, .infoCall: + // Note: These 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) + ) + + default: return Interaction.previewText( + variant: self.variant, + body: self.body + ) + } + } + + /// This menthod generates the preview text for a given transaction + static func previewText( + variant: Variant, + body: String?, + threadContactDisplayName: String = "", + authorDisplayName: String = "", + attachmentDescriptionInfo: Attachment.DescriptionInfo? = nil, + attachmentCount: Int? = nil, + isOpenGroupInvitation: Bool = false + ) -> String { + switch variant { + case .standardIncomingDeleted: return "" + + case .standardIncoming, .standardOutgoing: + let attachmentDescription: String? = Attachment.description( + for: attachmentDescriptionInfo, + count: attachmentCount + ) + + if + let attachmentDescription: String = attachmentDescription, + let body: String = body, + !attachmentDescription.isEmpty, + !body.isEmpty + { + if CurrentAppContext().isRTL { + return "\(body): \(attachmentDescription)" + } + + return "\(attachmentDescription): \(body)" + } + + if let body: String = body, !body.isEmpty { + return body + } + + if let attachmentDescription: String = attachmentDescription, !attachmentDescription.isEmpty { + return attachmentDescription + } + + if isOpenGroupInvitation { + return "😎 Open group invitation" + } + + // TODO: We should do better here + return "" + + case .infoMediaSavedNotification: + // TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved + return String(format: "media_saved".localized(), authorDisplayName) + + case .infoScreenshotNotification: + return String(format: "screenshot_taken".localized(), authorDisplayName) + + 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: + 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 + + 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(threadContactDisplayName: threadContactDisplayName) + } + } + + func state(_ db: Database) throws -> RecipientState.State { + let states: [RecipientState.State] = try RecipientState.State + .fetchAll( + db, + 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 { + 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..05feb2132 --- /dev/null +++ b/SessionMessagingKit/Database/Models/InteractionAttachment.swift @@ -0,0 +1,46 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +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]) + public 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 albumIndex + case interactionId + case attachmentId + } + + public let albumIndex: Int + 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: - Initialization + + public init( + albumIndex: Int, + interactionId: Int64, + attachmentId: String + ) { + self.albumIndex = albumIndex + self.interactionId = interactionId + self.attachmentId = attachmentId + } +} diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift new file mode 100644 index 000000000..21b3de9fa --- /dev/null +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -0,0 +1,574 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import AFNetworking +import SignalCoreKit +import SessionUtilitiesKit + +public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "linkPreview" } + internal static let interactionForeignKey = ForeignKey( + [Columns.url], + to: [Interaction.Columns.linkPreviewUrl] + ) + internal static let interactions = hasMany(Interaction.self, using: Interaction.linkPreviewForeignKey) + 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 + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case url + case timestamp + case variant + case title + case attachmentId + } + + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { + case standard + case openGroupInvitation + } + + /// The url for the link preview + public let url: String + + /// 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? + + /// 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: - Protobuf + +public extension LinkPreview { + init?(_ db: Database, proto: SNProtoDataMessage, body: String?, sentTimestampMs: TimeInterval) throws { + guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } + guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput } + guard LinkPreview.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput } + + // Try to get an existing link preview first + let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: sentTimestampMs) + 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 + return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) + } + + 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[..> = Atomic(NSCache()) + + static func previewUrl(for body: String?, selectedRange: NSRange? = nil) -> String? { + 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? { + 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 Storage.shared[.areLinkPreviewsEnabled] else { return } + + serialQueue.sync { + linkPreviewDraftCache = linkPreviewDraft + } + } + + static func tryToBuildPreviewInfo(previewUrl: String?) -> Promise { + guard Storage.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/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift new file mode 100644 index 000000000..71912d32d --- /dev/null +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -0,0 +1,248 @@ +// 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" } + internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) + private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) + 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 roomToken + case publicKey + case name + case isActive + case roomDescription = "description" + case imageId + case imageData + case userCount + case infoUpdates + case sequenceNumber + case inboxLatestMessageId + case outboxLatestMessageId + case pollFailureCount + } + + 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 + /// + /// **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 room + public let roomDescription: String? + + /// The ID with which the image can be retrieved from the server + public let imageId: String? + + /// The image for the group + public let imageData: Data? + + /// The number of users in the group + public let userCount: Int64 + + /// Monotonic room information counter that increases each time the room's metadata changes + 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 + + /// 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 { + request(for: OpenGroup.thread) + } + + 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) + } + + // MARK: - Initialization + + public init( + server: String, + roomToken: String, + publicKey: String, + isActive: Bool, + name: String, + roomDescription: String? = nil, + imageId: String? = nil, + imageData: Data? = nil, + userCount: Int64, + infoUpdates: Int64, + sequenceNumber: Int64 = 0, + inboxLatestMessageId: Int64 = 0, + outboxLatestMessageId: Int64 = 0, + pollFailureCount: Int64 = 0 + ) { + self.threadId = OpenGroup.idFor(roomToken: roomToken, server: server) + self.server = server.lowercased() + self.roomToken = roomToken + self.publicKey = publicKey + self.isActive = isActive + self.name = name + self.roomDescription = roomDescription + self.imageId = imageId + self.imageData = imageData + self.userCount = userCount + self.infoUpdates = infoUpdates + self.sequenceNumber = sequenceNumber + self.inboxLatestMessageId = inboxLatestMessageId + self.outboxLatestMessageId = outboxLatestMessageId + self.pollFailureCount = pollFailureCount + } +} + +// MARK: - GRDB Interactions + +public extension OpenGroup { + static func fetchOrCreate( + _ db: Database, + server: String, + roomToken: String, + publicKey: String + ) -> OpenGroup { + 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: roomToken, // Default the name to the `roomToken` until we get retrieve the actual name + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0, + pollFailureCount: 0 + ) + } + + return existingGroup + } +} + +// MARK: - Convenience + +public extension OpenGroup { + static func idFor(roomToken: String, server: String) -> String { + // Always force the server to lowercase + return "\(server.lowercased()).\(roomToken)" + } +} + +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)", + "pollFailureCount: \(pollFailureCount))" + ].joined(separator: ", ") + } +} + +// MARK: - Objective-C Support + +// TODO: Remove this when possible + +@objc(SMKOpenGroup) +public class SMKOpenGroup: NSObject { + @objc(inviteUsers:toOpenGroupFor:) + public static func invite(selectedUsers: Set, openGroupThreadId: String) { + 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)" + + 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: thread.id, + 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 new file mode 100644 index 000000000..e57a95422 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -0,0 +1,388 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +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]) + 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 + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + + case name + case nickname + + case 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 let name: String + + /// A custom name for the profile set by the current user + public let nickname: String? + + /// The URL from which to fetch the contact's profile picture. + public let profilePictureUrl: String? + + /// The file name of the contact's profile picture on local storage. + public let profilePictureFileName: String? + + /// 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 { + """ + Profile( + name: \(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 StorageError.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: - Mutation + +public extension Profile { + func with( + name: String? = nil, + profilePictureUrl: Updatable = .existing, + profilePictureFileName: Updatable = .existing, + profileEncryptionKey: Updatable = .existing + ) -> Profile { + return Profile( + id: id, + name: (name ?? self.name), + nickname: self.nickname, + profilePictureUrl: (profilePictureUrl ?? self.profilePictureUrl), + profilePictureFileName: (profilePictureFileName ?? self.profilePictureFileName), + profileEncryptionKey: (profileEncryptionKey ?? self.profileEncryptionKey) + ) + } +} + +// MARK: - GRDB Interactions + +public extension Profile { + static func fetchAllContactProfiles(excluding: Set = [], excludeCurrentUser: Bool = true) -> [Profile] { + return Storage.shared + .read { db in + let idsToExclude: Set = excluding + .inserting(excludeCurrentUser ? getUserHexEncodedPublicKey(db) : nil) + + // Sort the contacts by their displayName value + return try Profile + .filter(!idsToExclude.contains(Profile.Columns.id)) + .joining( + required: Profile.contact + .filter(Contact.Columns.isApproved == true) + .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, threadVariant: SessionThread.Variant = .contact, customFallback: String? = nil) -> String { + guard let db: Database = db else { + return Storage.shared + .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: threadVariant) + + return (existingDisplayName ?? (customFallback ?? id)) + } + + static func displayNameNoFallback(_ db: Database? = nil, id: ID, threadVariant: SessionThread.Variant = .contact) -> String? { + guard let db: Database = db else { + return Storage.shared.read { db in displayNameNoFallback(db, id: id, threadVariant: threadVariant) } + } + + return (try? Profile.fetchOne(db, id: id))? + .displayName(for: threadVariant) + } + + // 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 + ) + } + + /// 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 = "" + + let exisingProfile: Profile? = Storage.shared.read { db in + userPublicKey = getUserHexEncodedPublicKey(db) + + return try Profile.fetchOne(db, id: userPublicKey) + } + + 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) + + return ( + (try? Profile.fetchOne(db, id: userPublicKey)) ?? + defaultFor(userPublicKey) + ) + } + + /// 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? = Storage.shared.read { db in + try Profile.fetchOne(db, id: id) + } + + 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)) ?? + defaultFor(id) + ) + } +} + +// MARK: - Convenience + +public extension Profile { + // MARK: - Truncation + + enum Truncation { + case start + case middle + case end + } + + /// A standardised mechanism for truncating a user id for a given thread + static func truncated(id: String, threadVariant: SessionThread.Variant = .contact) -> String { + return truncated(id: id, truncating: .middle) + } + + /// A standardised mechanism for truncating a user id + static func truncated(id: String, truncating: Truncation = .middle) -> 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 = .contact) -> String { + return Profile.displayName(for: threadVariant, id: id, name: name, nickname: nickname) + } + + static func displayName( + for threadVariant: SessionThread.Variant, + id: String, + name: String?, + nickname: String?, + customFallback: String? = nil + ) -> String { + if let nickname: String = nickname { return nickname } + + guard let name: String = name, name != id else { + return (customFallback ?? Profile.truncated(id: id, threadVariant: threadVariant)) + } + + switch threadVariant { + case .contact, .closedGroup: 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: .middle)))" + } + } +} + +// MARK: - Objective-C Support + +// FIXME: Remove when possible + +@objc(SMKProfile) +public class SMKProfile: NSObject { + @objc public static func displayName(id: String) -> String { + return Profile.displayName(id: id) + } + + @objc public static func displayName(id: String, customFallback: String) -> String { + return Profile.displayName(id: id, customFallback: customFallback) + } + + @objc(displayNameAfterSavingNickname:forProfileId:) + public static func displayNameAfterSaving(nickname: String?, for profileId: String) -> String { + return Storage.shared.write { db in + let profile: Profile = Profile.fetchOrCreate(id: profileId) + let targetNickname: String? = ((nickname ?? "").count > 0 ? nickname : nil) + + try Profile + .filter(id: profile.id) + .updateAll(db, Profile.Columns.nickname.set(to: targetNickname)) + + return (targetNickname ?? profile.name) + } + .defaulting(to: "") + } +} diff --git a/SessionMessagingKit/Database/Models/Quote.swift b/SessionMessagingKit/Database/Models/Quote.swift new file mode 100644 index 000000000..633676aa6 --- /dev/null +++ b/SessionMessagingKit/Database/Models/Quote.swift @@ -0,0 +1,139 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +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( + [Columns.timestampMs, Columns.authorId], + to: [Interaction.Columns.timestampMs, Interaction.Columns.authorId] + ) + internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.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) + public static let attachment = hasOne(Attachment.self, using: Attachment.quoteForeignKey) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case interactionId + case authorId + case timestampMs + case body + case attachmentId + } + + /// 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: 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 { + 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) + } + + // 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: - Protobuf + +public extension Quote { + init?(_ db: Database, proto: SNProtoDataMessage, interactionId: Int64, thread: SessionThread) throws { + guard + let quoteProto = proto.quote, + quoteProto.id != 0, + !quoteProto.author.isEmpty + else { return nil } + + self.interactionId = interactionId + 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 == 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 = quoteProto.text, !body.isEmpty { + self.body = body + } + else { + self.body = nil + } + + // We only use the first attachment + 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 + if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) { + return attachment.cloneAsQuoteThumbnail() + } + + // Otherwise if the quotedInteraction has a link preview, try clone that + return try? quotedInteraction.linkPreview + .fetchOne(db)? + .attachment + .fetchOne(db)? + .cloneAsQuoteThumbnail() + } + .defaulting(to: Attachment(proto: attachment)) + .inserted(db) + .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 new file mode 100644 index 000000000..9c29d2002 --- /dev/null +++ b/SessionMessagingKit/Database/Models/RecipientState.swift @@ -0,0 +1,133 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +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) + internal 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 + case mostRecentFailureText + } + + public enum State: Int, Codable, Hashable, DatabaseValueConvertible { + /// 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 .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 { + 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 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 + 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 let readTimestampMs: Int64? + + public let mostRecentFailureText: String? + + // MARK: - Relationships + + public var interaction: QueryInterfaceRequest { + request(for: RecipientState.interaction) + } + + 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 new file mode 100644 index 000000000..452ab8c49 --- /dev/null +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -0,0 +1,421 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import SessionUtilitiesKit + +public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "thread" } + 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 + ) + 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 { + case id + case variant + case creationDateTimestamp + case shouldBeVisible + case isPinned + case messageDraft + case notificationSound + case mutedUntilTimestamp + case onlyNotifyForMentions + } + + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { + case contact + case closedGroup + case openGroup + } + + /// 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 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? + + /// A flag indicating whether the thread should only notify for mentions + public let onlyNotifyForMentions: Bool + + // MARK: - Relationships + + public var contact: QueryInterfaceRequest { + request(for: SessionThread.contact) + } + + public var closedGroup: QueryInterfaceRequest { + request(for: SessionThread.closedGroup) + } + + public var openGroup: QueryInterfaceRequest { + request(for: SessionThread.openGroup) + } + + public var disappearingMessagesConfiguration: QueryInterfaceRequest { + request(for: SessionThread.disappearingMessagesConfiguration) + } + + public var interactions: QueryInterfaceRequest { + request(for: SessionThread.interactions) + } + + public var typingIndicator: QueryInterfaceRequest { + request(for: SessionThread.typingIndicator) + } + + // MARK: - Initialization + + public init( + id: String, + variant: Variant, + creationDateTimestamp: TimeInterval = Date().timeIntervalSince1970, + shouldBeVisible: Bool = false, + isPinned: Bool = false, + messageDraft: String? = nil, + notificationSound: Preferences.Sound? = nil, + mutedUntilTimestamp: TimeInterval? = nil, + onlyNotifyForMentions: Bool = false + ) { + self.id = id + self.variant = variant + self.creationDateTimestamp = creationDateTimestamp + self.shouldBeVisible = shouldBeVisible + self.isPinned = isPinned + self.messageDraft = messageDraft + self.notificationSound = notificationSound + self.mutedUntilTimestamp = mutedUntilTimestamp + self.onlyNotifyForMentions = onlyNotifyForMentions + } + + // MARK: - Custom Database Interaction + + public func insert(_ db: Database) throws { + try performInsert(db) + + db[.hasSavedThread] = true + } +} + +// MARK: - Mutation + +public extension SessionThread { + func with( + shouldBeVisible: Bool? = nil, + isPinned: Bool? = nil + ) -> SessionThread { + return SessionThread( + id: id, + variant: variant, + creationDateTimestamp: creationDateTimestamp, + shouldBeVisible: (shouldBeVisible ?? self.shouldBeVisible), + isPinned: (isPinned ?? self.isPinned), + messageDraft: messageDraft, + notificationSound: notificationSound, + mutedUntilTimestamp: mutedUntilTimestamp, + onlyNotifyForMentions: onlyNotifyForMentions + ) + } +} + +// MARK: - GRDB Interactions + +public extension SessionThread { + /// 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 + } + + func isMessageRequest(_ db: Database, includeNonVisible: Bool = false) -> Bool { + return ( + (includeNonVisible || shouldBeVisible) && + variant == .contact && + id != getUserHexEncodedPublicKey(db) && // Note to self + (try? Contact + .filter(id: id) + .select(.isApproved) + .asRequest(of: Bool.self) + .fetchOne(db)) + .defaulting(to: false) == false + ) + } +} + +// MARK: - Convenience + +public extension SessionThread { + static func messageRequestsQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + + return """ + SELECT \(thread.allColumns()) + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + WHERE ( + \(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: includeNonVisible)) + ) + """ + } + + static func unreadMessageRequestsThreadIdQuery(userPublicKey: String, includeNonVisible: Bool = false) -> SQLRequest { + let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + + return """ + SELECT \(thread[.id]) + FROM \(SessionThread.self) + JOIN \(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, includeNonVisible: includeNonVisible)) + ) + GROUP BY \(thread[.id]) + """ + } + + /// 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, 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( + """ + \(shouldBeVisibleSQL) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND + IFNULL(\(contact[.isApproved]), false) = false + """ + ) + } + + func isNoteToSelf(_ db: Database? = nil) -> Bool { + return ( + variant == .contact && + id == getUserHexEncodedPublicKey(db) + ) + } + + 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, + 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 else { return "NOTE_TO_SELF".localized() } + guard let profile: Profile = profile else { + return Profile.truncated(id: threadId, truncating: .middle) + } + + 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 + +// FIXME: Remove when possible + +@objc(SMKThread) +public class SMKThread: NSObject { + @objc(deleteAll) + public static func deleteAll() { + Storage.shared.writeAsync { db in + _ = try SessionThread.deleteAll(db) + } + } + + @objc(isThreadMuted:) + public static func isThreadMuted(_ threadId: String) -> Bool { + return Storage.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 Storage.shared.read { db in + return try SessionThread + .select(SessionThread.Columns.onlyNotifyForMentions) + .filter(id: threadId) + .asRequest(of: Bool.self) + .fetchOne(db) + } + .defaulting(to: false) + } + + @objc(setIsOnlyNotifyingForMentions:to:) + public static func isOnlyNotifyingForMentions(_ threadId: String, isEnabled: Bool) { + Storage.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 Storage.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) { + Storage.shared.write { db in + try SessionThread + .filter(id: threadId) + .updateAll(db, SessionThread.Columns.mutedUntilTimestamp.set(to: date?.timeIntervalSince1970)) + } + } +} 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/Database/Notification+Contacts.swift b/SessionMessagingKit/Database/Notification+Contacts.swift deleted file mode 100644 index 74d855ea0..000000000 --- a/SessionMessagingKit/Database/Notification+Contacts.swift +++ /dev/null @@ -1,12 +0,0 @@ - -public extension Notification.Name { - - static let contactUpdated = Notification.Name("contactUpdated") - static let contactBlockedStateChanged = Notification.Name("contactBlockedStateChanged") -} - -@objc public extension NSNotification { - - @objc static let contactUpdated = Notification.Name.contactUpdated.rawValue as NSString - @objc static let contactBlockedStateChanged = Notification.Name.contactBlockedStateChanged.rawValue as NSString -} 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 fe5120ca4..000000000 --- a/SessionMessagingKit/Database/OWSPrimaryStorage.m +++ /dev/null @@ -1,392 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSPrimaryStorage.h" -#import "AppContext.h" -#import "OWSDisappearingMessagesFinder.h" -#import "OWSFileSystem.h" -#import "OWSIncomingMessageFinder.h" -#import "OWSMediaGalleryFinder.h" -#import -#import "OWSStorage.h" -#import "OWSStorage+Subclass.h" -#import "SSKEnvironment.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]; - - self.database.maxConnectionPoolCount = 5; // Increase max connection pool count, default is 3. - _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]; - [TSDatabaseView asyncRegisterUnreadDatabaseView:self]; - [self asyncRegisterExtension:[TSDatabaseSecondaryIndexes registerTimeStampIndex] - withName:[TSDatabaseSecondaryIndexes registerTimeStampIndexExtensionName]]; - - [TSDatabaseView asyncRegisterUnseenDatabaseView:self]; - [TSDatabaseView asyncRegisterUnreadMentionDatabaseView:self]; - [TSDatabaseView asyncRegisterThreadOutgoingMessagesDatabaseView:self]; - - [FullTextSearchFinder asyncRegisterDatabaseExtensionWithStorage:self]; - [OWSIncomingMessageFinder asyncRegisterExtensionWithPrimaryStorage:self]; - [OWSDisappearingMessagesFinder asyncRegisterDatabaseExtensions:self]; - [OWSMediaGalleryFinder asyncRegisterDatabaseExtensionsWithPrimaryStorage: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 01d4e9924..000000000 --- a/SessionMessagingKit/Database/OWSStorage.m +++ /dev/null @@ -1,809 +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 "TSAttachmentStream.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/SSKPreferences.swift b/SessionMessagingKit/Database/SSKPreferences.swift deleted file mode 100644 index 25ab1ba7f..000000000 --- a/SessionMessagingKit/Database/SSKPreferences.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@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 areCallsEnabledKey = "areCallsEnabled" - - @objc - public static var areCallsEnabled: Bool { - get { - return getBool(key: areCallsEnabledKey, defaultValue: false) - } - set { - setBool(newValue, key: areCallsEnabledKey) - } - } - - @objc - public static var isCallKitSupported: Bool { - let userLocale = NSLocale.current - - guard let regionCode = userLocale.regionCode else { return false } - - if regionCode.contains("CN") || - regionCode.contains("CHN") { - return false - } else { - return true - } - } - - // 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) - } -} diff --git a/SessionMessagingKit/Database/Storage+Calls.swift b/SessionMessagingKit/Database/Storage+Calls.swift deleted file mode 100644 index c1de95a9c..000000000 --- a/SessionMessagingKit/Database/Storage+Calls.swift +++ /dev/null @@ -1,16 +0,0 @@ - -extension Storage { - - private static let receivedCallsCollection = "LokiReceivedCallsCollection" - - public func getReceivedCalls(for publicKey: String, using transaction: Any) -> Set { - var result: Set? - guard let transaction = transaction as? YapDatabaseReadTransaction else { return [] } - result = transaction.object(forKey: publicKey, inCollection: Storage.receivedCallsCollection) as? Set - return result ?? [] - } - - public func setReceivedCalls(to receivedCalls: Set, for publicKey: String, using transaction: Any) { - (transaction as! YapDatabaseReadWriteTransaction).setObject(receivedCalls, forKey: publicKey, inCollection: Storage.receivedCallsCollection) - } -} diff --git a/SessionMessagingKit/Database/Storage+ClosedGroups.swift b/SessionMessagingKit/Database/Storage+ClosedGroups.swift deleted file mode 100644 index a21d6d551..000000000 --- a/SessionMessagingKit/Database/Storage+ClosedGroups.swift +++ /dev/null @@ -1,102 +0,0 @@ - -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) -> [ECKeyPair] { - var result: [ECKeyPair] = [] - Storage.read { transaction in - result = self.getClosedGroupEncryptionKeyPairs(for: groupPublicKey, using: transaction) - } - return result - } - - 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) -> ECKeyPair? { - 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: ECKeyPair, for groupPublicKey: String, using transaction: Any) { - let collection = Storage.getClosedGroupEncryptionKeyPairCollection(for: groupPublicKey) - let timestamp = String(Date().timeIntervalSince1970) - (transaction as! YapDatabaseReadWriteTransaction).setObject(keyPair, 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+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+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 76cf9cd4d..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 == getUserPublicKey() { - 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/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 d8f5de95d..000000000 --- a/SessionMessagingKit/Database/Storage+Shared.swift +++ /dev/null @@ -1,56 +0,0 @@ -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 getUserPublicKey() -> 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) - } - - 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 - } -} 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 d31c677d9..000000000 --- a/SessionMessagingKit/Database/TSDatabaseSecondaryIndexes.m +++ /dev/null @@ -1,54 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSDatabaseSecondaryIndexes.h" -#import "OWSStorage.h" -#import "TSInteraction.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 817347368..000000000 --- a/SessionMessagingKit/Database/TSDatabaseView.h +++ /dev/null @@ -1,79 +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; - -// 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. -+ (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 ec47b53b7..000000000 --- a/SessionMessagingKit/Database/TSDatabaseView.m +++ /dev/null @@ -1,485 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSDatabaseView.h" -#import "OWSReadTracking.h" -#import "TSAttachment.h" -#import "TSAttachmentPointer.h" -#import "TSIncomingMessage.h" -#import "TSOutgoingMessage.h" -#import "TSThread.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)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 *( - 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; - SNContact *contact = [LKStorage.shared getContactWithSessionID:[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/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift new file mode 100644 index 000000000..694bf53be --- /dev/null +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -0,0 +1,87 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionSnodeKit +import SessionUtilitiesKit + +@objc(SNFileServerAPI) +public final class FileServerAPI: NSObject { + + // 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" + public static let serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" + 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 + /// be other parameters in the request. On average the multiplier appears to be about 1.5, so when checking whether the file will exceed the file size limit when + /// uploading a file we just divide the size of the file by this number. The alternative would be to actually check the size of the HTTP request but that's only + /// 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: - File Storage + + public static func upload(_ file: Data) -> Promise { + let request = Request( + method: .post, + server: server, + endpoint: Endpoint.file, + headers: [ + .contentDisposition: "attachment", + .contentType: "application/octet-stream" + ], + body: Array(file) + ) + + return send(request, serverPublicKey: serverPublicKey) + .decoded(as: FileUploadResponse.self, on: .global(qos: .userInitiated)) + } + + 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: fileId) + ) + + return send(request, serverPublicKey: serverPublicKey) + } + + public static func getVersion(_ platform: String) -> Promise { + let request = Request( + server: server, + endpoint: .sessionVersion, + queryParameters: [ + .platform: platform + ] + ) + + return send(request, serverPublicKey: serverPublicKey) + .decoded(as: VersionResponse.self, on: .global(qos: .userInitiated)) + .map { response in response.version } + } + + // MARK: - Convenience + + private static func send(_ request: Request, serverPublicKey: String) -> Promise { + let urlRequest: URLRequest + + do { + urlRequest = try request.generateUrlRequest() + } + catch { + return Promise(error: error) + } + + 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/FileServerAPIV2.swift b/SessionMessagingKit/File Server/FileServerAPIV2.swift deleted file mode 100644 index a5b880b36..000000000 --- a/SessionMessagingKit/File Server/FileServerAPIV2.swift +++ /dev/null @@ -1,125 +0,0 @@ -import PromiseKit -import SessionSnodeKit - -@objc(SNFileServerAPIV2) -public final class FileServerAPIV2 : NSObject { - - // 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" - public static let serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" - public static let maxFileSize = 10_000_000 // 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 - /// be other parameters in the request. On average the multiplier appears to be about 1.5, so when checking whether the file will exceed the file size limit when - /// uploading a file we just divide the size of the file by this number. The alternative would be to actually check the size of the HTTP request but that's only - /// 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: 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: Request - private struct Request { - let verb: HTTP.Verb - let endpoint: String - let queryParameters: [String:String] - let parameters: JSON - let headers: [String: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) { - self.verb = verb - self.endpoint = endpoint - self.queryParameters = queryParameters - self.parameters = parameters - 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 - let tsRequest: TSRequest - 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) - } - tsRequest.allHTTPHeaderFields = request.headers - if request.useOnionRouting { - return OnionRequestAPI.sendOnionRequest(tsRequest, to: server, using: serverPublicKey) - } else { - preconditionFailure("It's currently not allowed to send non onion routed requests.") - } - } - - // MARK: File Storage - @objc(upload:) - public static func objc_upload(file: Data) -> AnyPromise { - return AnyPromise.from(upload(file).map { String($0) }) - } - - 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 - } - } - - @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)) } - return AnyPromise.from(download(id, useOldServer: useOldServer)) - } - - 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 - } - } - - 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 - } - } -} diff --git a/SessionMessagingKit/File Server/Models/VersionResponse.swift b/SessionMessagingKit/File Server/Models/VersionResponse.swift new file mode 100644 index 000000000..fcb4a934c --- /dev/null +++ b/SessionMessagingKit/File Server/Models/VersionResponse.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension FileServerAPI { + struct VersionResponse: Codable { + enum CodingKeys: String, CodingKey { + case version = "version" + } + + public let version: String + } +} diff --git a/SessionMessagingKit/File Server/Types/FSEndpoint.swift b/SessionMessagingKit/File Server/Types/FSEndpoint.swift new file mode 100644 index 000000000..5e242bea8 --- /dev/null +++ b/SessionMessagingKit/File Server/Types/FSEndpoint.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension FileServerAPI { + public enum Endpoint: EndpointType { + case file + case fileIndividual(fileId: String) + case sessionVersion + + var path: String { + switch self { + 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 deleted file mode 100644 index f85c66e35..000000000 --- a/SessionMessagingKit/Jobs/AttachmentDownloadJob.swift +++ /dev/null @@ -1,164 +0,0 @@ -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.mutate{ $0.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/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/AttachmentUploadJob.swift deleted file mode 100644 index 1e9af58b4..000000000 --- a/SessionMessagingKit/Jobs/AttachmentUploadJob.swift +++ /dev/null @@ -1,160 +0,0 @@ -import PromiseKit -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.mutate{ $0.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 - // On the recipient side, it only uses the fileID in this URL, - // so the host doesn't matter even we use file server host for - // opne group attachments. - 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/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/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 df8ebe02b..000000000 --- a/SessionMessagingKit/Jobs/JobQueue.swift +++ /dev/null @@ -1,117 +0,0 @@ -import SessionUtilitiesKit - -@objc(SNJobQueue) -public final class JobQueue : NSObject, JobDelegate { - - private static var jobIDs: [UInt64:UInt64] = [:] - - internal static var currentlyExecutingJobs: Atomic> = Atomic([]) - - @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.wrappedValue.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) { - JobQueue.currentlyExecutingJobs.mutate { $0.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/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift deleted file mode 100644 index 16827b0de..000000000 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ /dev/null @@ -1,99 +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.mutate { $0.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) - } - seal.fulfill(()) // The promise is just used to keep track of when we're done - } - }, completion: { }) - 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 1a302c174..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.mutate{ $0.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 b6c9c1579..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.mutate{ $0.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/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift new file mode 100644 index 000000000..6a1d4fc16 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -0,0 +1,225 @@ +// 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: Int = 3 + public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = true + + public static func run( + _ job: Job, + queue: DispatchQueue, + 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: Attachment = Storage.shared + .read({ db in try Attachment.fetchOne(db, id: details.attachmentId) }) + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + return + } + + // Due to the complex nature of jobs and how attachments can be reused it's possible for + // 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) { + Storage.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) + Storage.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 + ) + let downloadPromise: Promise = { + guard + let downloadUrl: String = attachment.downloadUrl, + let fileId: String = Attachment.fileId(for: downloadUrl) + else { + return Promise(error: AttachmentDownloadError.invalidUrl) + } + + 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 + } + + return OpenGroupAPI.downloadFile(db, fileId: fileId, from: openGroup.roomToken, on: openGroup.server) + .map { _, data in data } + }) + + return ( + maybeOpenGroupDownloadPromise ?? + FileServerAPI.download(fileId, useOldServer: downloadUrl.contains(FileServerAPI.oldServer)) + ) + }() + + downloadPromise + .then(on: queue) { data -> Promise in + try data.write(to: temporaryFileUrl, 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(on: queue) { + // Remove the temporary file + OWSFileSystem.deleteFile(temporaryFileUrl.path) + + /// 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 + Storage.shared.write { db in + _ = try attachment + .with( + state: .downloaded, + creationTimestamp: Date().timeIntervalSince1970, + localRelativeFilePath: ( + attachment.localRelativeFilePath ?? + Attachment.localRelativeFilePath(from: attachment.originalFilePath) + ) + ) + .saved(db) + } + + success(job, false) + } + .catch(on: queue) { error in + OWSFileSystem.deleteFile(temporaryFileUrl.path) + + let targetState: Attachment.State + let permanentFailure: Bool + + switch error { + /// 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: + 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 + Storage.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) + } + } +} + +// MARK: - AttachmentDownloadJob.Details + +extension AttachmentDownloadJob { + public struct Details: Codable { + public let attachmentId: String + + public init(attachmentId: String) { + self.attachmentId = attachmentId + } + } + + 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" + } + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift new file mode 100644 index 000000000..4692d48b1 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -0,0 +1,98 @@ +// 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, + queue: DispatchQueue, + 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?) = Storage.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 + } + + // 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 + } + } + + // 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 + if let openGroup: OpenGroup = openGroup { + return OpenGroupAPI + .uploadFile( + db, + bytes: data.bytes, + to: openGroup.roomToken, + on: openGroup.server + ) + .map { _, response -> String in response.id } + } + + return FileServerAPI.upload(data) + .map { response -> String in response.id } + }, + encrypt: (openGroup == nil), + success: { _ in success(job, false) }, + failure: { error in failure(job, error, false) } + ) + } +} + +// MARK: - AttachmentUploadJob.Details + +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(messageSendJobId: Int64, attachmentId: String) { + self.messageSendJobId = messageSendJobId + self.attachmentId = attachmentId + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift new file mode 100644 index 000000000..9ed31c7e7 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -0,0 +1,108 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public enum DisappearingMessagesJob: 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, + queue: DispatchQueue, + 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? = Storage.shared.write { db in + _ = try Interaction + .filter(Interaction.Columns.expiresStartedAtMs != nil) + .filter((Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)) <= timestampNowMs) + .deleteAll(db) + + // 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) + + // 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 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? 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 } + + 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) -> Job? { + // 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 nil } + + return updateNextRunIfNeeded(db) + } + + @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 nil + } + + do { + guard let interactionId: Int64 = try? (interaction.id ?? interaction.inserted(db).id) else { + throw StorageError.objectNotFound + } + + return updateNextRunIfNeeded(db, interactionIds: [interactionId], startedAtMs: startedAtMs) + } + catch { + SNLog("Failed to update the expiring messages timer on an interaction") + return nil + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift new file mode 100644 index 000000000..a2d921eee --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -0,0 +1,31 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +public enum FailedAttachmentDownloadsJob: 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, + queue: DispatchQueue, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + // Update all 'sending' message states to 'failed' + 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)) + + Logger.debug("Marked \(changeCount) attachments as failed") + } + + success(job, false) + } +} diff --git a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift new file mode 100644 index 000000000..b83e2e31e --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -0,0 +1,34 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit +import SessionUtilitiesKit + +public enum FailedMessageSendsJob: 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, + queue: DispatchQueue, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + // Update all 'sending' message states to 'failed' + 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)) + let attachmentChangeCount: Int = try Attachment + .filter(Attachment.Columns.state == Attachment.State.uploading) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + + SNLog("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 new file mode 100644 index 000000000..0777de7a2 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -0,0 +1,444 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +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 + 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, + queue: DispatchQueue, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) { + /// 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 + + /// 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 + if typesToCollect.contains(.expiredControlMessageProcessRecords) { + _ = try ControlMessageProcessRecord + .filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow) + .deleteAll(db) + } + + /// Remove any typing indicators + if typesToCollect.contains(.threadTypingIndicators) { + _ = try ThreadTypingIndicator + .deleteAll(db) + } + + /// Remove any old open group messages - open group messages which are older than six months + 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) + 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]) + ) + 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) + ) + ) + """) + } + + /// Orphaned jobs - jobs which have had their threads or interactions removed + if 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 - link previews which have no interactions with matching url & rounded timestamps + if 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 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 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 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 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 + ) + ) + """) + } + + /// 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 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 + ) + ) + """) + } + + 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) + queue.async { + // Retrieve a list of all valid attachmnet and avatar file paths + struct FileInfo { + let attachmentLocalRelativePaths: Set + let profileAvatarFilenames: Set + } + + let maybeFileInfo: FileInfo? = Storage.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 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 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 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) } + } + + SNLog("[GarbageCollectionJob] Removed \(orphanedAttachmentFiles.count) orphaned attachment\(orphanedAttachmentFiles.count == 1 ? "" : "s")") + } + + // Orphaned profile avatar files (actual deletion) + if 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) } + } + + 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) + guard deletionErrors.isEmpty else { + failure(job, (deletionErrors.first ?? StorageError.generic), false) + return + } + + // Update the 'lastGarbageCollection' date to prevent this job from running again + // for the next 23 hours + UserDefaults.standard[.lastGarbageCollection] = Date() + success(job, false) + } + } + ) + } +} + +// MARK: - GarbageCollectionJob.Details + +extension GarbageCollectionJob { + public enum Types: Codable, CaseIterable { + case expiredControlMessageProcessRecords + case threadTypingIndicators + case oldOpenGroupMessages + case orphanedJobs + case orphanedLinkPreviews + case orphanedOpenGroups + case orphanedOpenGroupCapabilities + case orphanedBlindedIdLookups + case approvedBlindedContactRecords + case orphanedProfiles + case orphanedAttachments + case orphanedAttachmentFiles + case orphanedProfileAvatars + } + + public struct Details: Codable { + public let typesToCollect: [Types] + + public init(typesToCollect: [Types] = Types.allCases) { + self.typesToCollect = typesToCollect + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift new file mode 100644 index 000000000..6822f1fe0 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -0,0 +1,177 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SessionUtilitiesKit + +public enum MessageReceiveJob: JobExecutor { + public static var maxFailureCount: Int = 10 + public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = false + + public static func run( + _ job: Job, + queue: DispatchQueue, + 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 updatedJob: Job = job + var leastSevereError: Error? + + Storage.shared.write { db in + var remainingMessagesToProcess: [Details.MessageInfo] = [] + + for messageInfo in details.messages { + do { + try MessageReceiver.handle( + db, + message: messageInfo.message, + associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), + openGroupId: nil, + isBackgroundPoll: details.isBackgroundPoll + ) + } + catch { + // 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 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)") + 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) + } + } + } + + // 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 leastSevereError { + case let error as MessageReceiverError where !error.isRetryable: + failure(updatedJob, error, true) + + case .some(let error): + failure(updatedJob, error, false) // TODO: Confirm the 'noKeyPair' errors here aren't an issue + + case .none: + success(updatedJob, false) + } + } +} + +// MARK: - MessageReceiveJob.Details + +extension MessageReceiveJob { + public struct Details: Codable { + public struct MessageInfo: Codable { + 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( + 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.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) + } + } + + public let messages: [MessageInfo] + public let isBackgroundPoll: Bool + + public init( + messages: [MessageInfo], + isBackgroundPoll: Bool + ) { + self.messages = messages + self.isBackgroundPoll = isBackgroundPoll + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift new file mode 100644 index 000000000..8e9aa3732 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -0,0 +1,264 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SignalCoreKit +import SessionUtilitiesKit +import SessionSnodeKit + +public enum MessageSendJob: 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, + queue: DispatchQueue, + 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 + } + + // 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, + let interactionId: Int64 = job.interactionId + else { + failure(job, JobRunnerError.missingRequiredDetails, false) + 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, 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, fileIds) + } + + // 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) + // + // 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 { 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( + with: .attachmentUpload, + details: AttachmentUploadJob.Details( + messageSendJobId: jobId, + attachmentId: stateInfo.attachmentId + ) + ) + } + .compactMap { stateInfo in + JobRunner + .insert( + db, + job: Job( + variant: .attachmentUpload, + behaviour: .runOnce, + threadId: job.threadId, + interactionId: interactionId, + details: AttachmentUploadJob.Details( + messageSendJobId: jobId, + 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) + let isMissingFileIds: Bool = (maybeFileIds.count != fileIds.count) + let hasPendingUploads: Bool = allAttachmentStateInfo.contains(where: { $0.state != .uploaded }) + + return ( + (isMissingFileIds && !hasPendingUploads), + hasPendingUploads, + fileIds + ) + } + + // 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, AttachmentError.notUploaded, true) + return + } + + // Defer the job if we found incomplete uploads + guard attachmentState?.shouldDefer == false else { + 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 + 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) + + // Perform the actual message sending + Storage.shared.writeAsync { db -> Promise in + try MessageSender.sendImmediate( + db, + message: details.message, + to: details.destination + .with(fileIds: messageFileIds), + interactionId: job.interactionId + ) + } + .done(on: queue) { _ in success(job, false) } + .catch(on: queue) { 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 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)).") + + if details.message is VisibleMessage { + guard + let interactionId: Int64 = job.interactionId, + 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) + return + } + } + + failure(job, error, false) + } + } + .retainUntilComplete() + } +} + +// MARK: - MessageSendJob.Details + +extension MessageSendJob { + public struct Details: Codable { + private enum CodingKeys: String, CodingKey { + case destination + case message + case variant + } + + public let destination: Message.Destination + public let message: Message + public let variant: Message.Variant? + + // MARK: - Initialization + + public init( + destination: Message.Destination, + message: Message + ) { + 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 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 + } + + self = Details( + destination: try container.decode(Message.Destination.self, forKey: .destination), + message: try variant.decode(from: container, forKey: .message) + ) + } + + 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 messageSend job due to unsupported variant") + throw StorageError.objectNotFound + } + + try container.encode(destination, forKey: .destination) + try container.encode(message, forKey: .message) + try container.encode(variant, forKey: .variant) + } + } +} diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift new file mode 100644 index 000000000..63885541a --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -0,0 +1,47 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionSnodeKit +import SessionUtilitiesKit + +public enum NotifyPushServerJob: JobExecutor { + public static var maxFailureCount: Int = 20 + public static var requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + + public static func run( + _ job: Job, + queue: DispatchQueue, + 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 + } + + 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() + } +} + +// MARK: - NotifyPushServerJob.Details + +extension NotifyPushServerJob { + public struct Details: Codable { + public let message: SnodeMessage + } +} diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift new file mode 100644 index 000000000..01c244019 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -0,0 +1,50 @@ +// 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, + queue: DispatchQueue, + 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 + } + + // 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) + + Storage.shared.write { db in + guard try OpenGroup.exists(db, id: defaultGroupId) == false else { return } + + _ = try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: "", + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: "", + userCount: 0, + infoUpdates: 0 + ) + .saved(db) + } + + OpenGroupManager.getDefaultRoomsIfNeeded() + .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 new file mode 100644 index 000000000..c9e8b8af3 --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -0,0 +1,160 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SessionUtilitiesKit + +public enum SendReadReceiptsJob: JobExecutor { + public static let maxFailureCount: Int = -1 + public static let requiresThreadId: Bool = false + public static let requiresInteractionId: Bool = false + private static let minRunFrequency: TimeInterval = 3 + + public static func run( + _ job: Job, + queue: DispatchQueue, + 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 + } + + Storage.shared + .writeAsync { db in + try MessageSender.sendImmediate( + db, + message: ReadReceipt( + timestamps: details.timestampMsValues.map { UInt64($0) } + ), + to: details.destination, + interactionId: nil + ) + } + .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 + var shouldFinishCurrentJob: Bool = false + let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + minRunFrequency) + + 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 + 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(updatedJob ?? job, shouldFinishCurrentJob) + } + .catch(on: queue) { 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(.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/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift new file mode 100644 index 000000000..260f150be --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -0,0 +1,66 @@ +// 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, + queue: DispatchQueue, + 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 { + // 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 + } + + // Note: The user defaults flag is updated in ProfileManager + let profile: Profile = Profile.fetchOrCreateCurrentUser() + let profileFilePath: String? = profile.profilePictureFileName + .map { ProfileManager.profileAvatarFilepath(filename: $0) } + + ProfileManager.updateLocal( + queue: queue, + profileName: profile.name, + image: nil, + imageFilePath: profileFilePath, + requiredSync: true, + 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/Messages/Control Messages/CallMessage.swift b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift index d0ff3caed..533f771ba 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(threadContactDisplayName: String) -> String { + switch state { + case .incoming: + return String( + format: "call_incoming".localized(), + threadContactDisplayName + ) + + case .outgoing: + return String( + format: "call_outgoing".localized(), + threadContactDisplayName + ) + + case .missed, .permissionDenied: + return String( + format: "call_missed".localized(), + threadContactDisplayName + ) + + // 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 832c6e91d..a8160efe1 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -1,27 +1,44 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import Curve25519Kit import SessionUtilitiesKit -public final class ClosedGroupControlMessage : ControlMessage { +public final class ClosedGroupControlMessage: ControlMessage { + private enum CodingKeys: String, CodingKey { + case kind + } + public var kind: Kind? 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 } } public override var isSelfSendValid: Bool { true } - public override var shouldBeRetryable: Bool { - switch kind { - case .new, .encryptionKeyPair: return true - default: return false - } - } + // MARK: - Kind - // MARK: Kind - public enum Kind : CustomStringConvertible { - case new(publicKey: Data, name: String, encryptionKeyPair: ECKeyPair, members: [Data], admins: [Data], expirationTimer: UInt32) + 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. /// /// - Note: `publicKey` is only set when an encryption key pair is sent in a one-to-one context (i.e. not in a group). @@ -34,20 +51,110 @@ 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" + } + } + + 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 + // MARK: - Key Pair Wrapper + + public struct KeyPairWrapper: Codable { public var publicKey: String? public var encryptedKeyPair: Data? @@ -57,16 +164,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) @@ -84,133 +183,118 @@ public final class ClosedGroupControlMessage : ControlMessage { } } - // MARK: Initialization - public override init() { super.init() } + // MARK: - Initialization - internal init(kind: Kind) { - super.init() + internal init(kind: Kind, sentTimestampMs: UInt64? = nil) { + super.init(sentTimestamp: sentTimestampMs) + 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.privateKey.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 + 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 } } - - // 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? ECKeyPair, - 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) - 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 - } + + // 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) } - 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") + // MARK: - Proto Conversion + + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ClosedGroupControlMessage? { + guard let closedGroupControlMessageProto = proto.dataMessage?.closedGroupControlMessage else { + return nil } - } - - // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> 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 - 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 - } - 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 + 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.removingIdPrefixIfNeeded().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(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 @@ -222,7 +306,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 { @@ -256,7 +340,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 { @@ -265,8 +349,9 @@ public final class ClosedGroupControlMessage : ControlMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ ClosedGroupControlMessage( kind: \(kind?.description ?? "null") @@ -274,3 +359,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 d227ec9e4..a2d7bf096 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift @@ -1,88 +1,62 @@ +// 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 } - - let displayName = user.name - let profilePictureURL = user.profilePictureURL - let profileKey = user.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)") - } - - 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 { + public static func getCurrent(_ db: Database) throws -> ConfigurationMessage { + 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 } + 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() + // 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) + .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 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 +69,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 00eea0aa0..9ffa5e6ce 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift @@ -1,67 +1,103 @@ +// 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 var closedGroups: Set = [] +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? - public var profilePictureURL: 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() } + // 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 } + + // 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) } - 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: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> ConfigurationMessage? { + // 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 { Contact.fromProto($0) }) - return ConfigurationMessage(displayName: displayName, profilePictureURL: profilePictureURL, profileKey: profileKey, - closedGroups: closedGroups, openGroups: openGroups, contacts: contacts) + let contacts = Set(configurationProto.contacts.compactMap { CMContact.fromProto($0) }) + + return ConfigurationMessage( + displayName: displayName, + profilePictureUrl: profilePictureUrl, + profileKey: profileKey, + 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) } + if let profilePictureUrl = profilePictureUrl { configurationProto.setProfilePicture(profilePictureUrl) } if let profileKey = profileKey { configurationProto.setProfileKey(profileKey) } configurationProto.setClosedGroups(closedGroups.compactMap { $0.toProto() }) configurationProto.setOpenGroups([String](openGroups)) @@ -76,83 +112,112 @@ 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: \([Contact](contacts).prettifiedDescription) + contacts: \([CMContact](contacts).prettifiedDescription) ) """ } } -// MARK: Closed Group -extension ConfigurationMessage { +// MARK: - Closed Group - @objc(SNClosedGroup) - public final class ClosedGroup : NSObject, 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 + case encryptionKeyPublicKey + case encryptionKeySecretKey + case members + case admins + case expirationTimer + } + 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) { + + // 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.encryptionKeyPair = encryptionKeyPair + self.encryptionKeyPublicKey = encryptionKeyPublicKey + self.encryptionKeySecretKey = encryptionKeySecretKey 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 + // MARK: - Codable + + 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) + 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) + } + + 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(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 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") - } - - 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 } @@ -163,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).") @@ -180,18 +248,31 @@ extension ConfigurationMessage { } } - public override var description: String { name } + public var description: String { name } } } -// MARK: Contact -extension ConfigurationMessage { +// MARK: - Contact - @objc(SNConfigurationMessageContact) - public final class Contact : NSObject, 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 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? + public var profilePictureUrl: String? public var profileKey: Data? public var hasIsApproved: Bool @@ -204,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, @@ -217,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 @@ -226,40 +307,30 @@ 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) + + // MARK: - Codable + + 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) + 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 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") - } - - 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, + profilePictureUrl: proto.profilePicture, profileKey: proto.profileKey, hasIsApproved: proto.hasIsApproved, isApproved: proto.isApproved, @@ -277,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) } @@ -292,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 efa4d3862..09504cfd8 100644 --- a/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ControlMessage.swift @@ -1,3 +1,5 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -@objc(SNControlMessage) -public class ControlMessage : Message { } +import Foundation + +public class ControlMessage: Message { } diff --git a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift index c71e35a49..d54549df1 100644 --- a/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift +++ b/SessionMessagingKit/Messages/Control Messages/DataExtractionNotification.swift @@ -1,66 +1,70 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit -public final class DataExtractionNotification : ControlMessage { +public final class DataExtractionNotification: ControlMessage { + private enum CodingKeys: String, CodingKey { + case kind + } + public var kind: Kind? - // MARK: Kind - public enum Kind : CustomStringConvertible { + // MARK: - Kind + + 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 { - case .screenshot: return "screenshot" - case .mediaSaved: return "mediaSaved" + case .screenshot: return "screenshot" + case .mediaSaved: return "mediaSaved" } } } - // MARK: Initialization - public override init() { super.init() } - - internal init(kind: Kind) { + // MARK: - Initialization + + public 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 + 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 - } + + // 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) } - 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") - } - } - - // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> DataExtractionNotification? { + // MARK: - Proto Conversion + + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> DataExtractionNotification? { guard let dataExtractionNotification = proto.dataExtractionNotification else { return nil } let kind: Kind switch dataExtractionNotification.type { @@ -72,7 +76,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 @@ -95,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 98d42f357..18379089d 100644 --- a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift +++ b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift @@ -1,7 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 + } + /// 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. @@ -10,45 +18,57 @@ 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 } + + // 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) } - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - coder.encode(syncTarget, forKey: "syncTarget") - coder.encode(duration, forKey: "durationSeconds") - } - - // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> ExpirationTimerUpdate? { + // 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 +79,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 @@ -74,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"), @@ -83,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 29b57684f..8568b31b4 100644 --- a/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift +++ b/SessionMessagingKit/Messages/Control Messages/MessageRequestResponse.swift @@ -1,44 +1,56 @@ +// 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 - public init(isApproved: Bool) { + public init( + isApproved: Bool, + sentTimestampMs: UInt64? = nil + ) { self.isApproved = isApproved - super.init() + super.init( + sentTimestamp: sentTimestampMs + ) } - // MARK: - Coding - - public required init?(coder: NSCoder) { - guard let isApproved: Bool = coder.decodeObject(forKey: "isApproved") as? Bool else { return nil } + // MARK: - Codable + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - self.isApproved = isApproved + isApproved = try container.decode(Bool.self, forKey: .isApproved) - super.init(coder: coder) + try super.init(from: decoder) } - - public override func encode(with coder: NSCoder) { - super.encode(with: coder) + + public override func encode(to encoder: Encoder) throws { + try super.encode(to: encoder) - coder.encode(isApproved, forKey: "isApproved") + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encodeIfPresent(isApproved, forKey: .isApproved) } // 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 - - return MessageRequestResponse(isApproved: isApproved) + return MessageRequestResponse(isApproved: messageRequestResponseProto.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() @@ -53,7 +65,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 cdce7ae1e..9437e6503 100644 --- a/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift +++ b/SessionMessagingKit/Messages/Control Messages/ReadReceipt.swift @@ -1,44 +1,60 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit -@objc(SNReadReceipt) -public final class ReadReceipt : ControlMessage { - @objc public var timestamps: [UInt64]? +public final class ReadReceipt: ControlMessage { + private enum CodingKeys: String, CodingKey { + case timestamps + } + + 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 } + + // 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) } - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - coder.encode(timestamps, forKey: "messageTimestamps") - } - - // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> ReadReceipt? { + // 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 guard !timestamps.isEmpty else { return nil } 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 @@ -55,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 965fdfa38..d5d9058d4 100644 --- a/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift +++ b/SessionMessagingKit/Messages/Control Messages/TypingIndicator.swift @@ -1,70 +1,87 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 + } + public var kind: Kind? public override var ttl: UInt64 { 20 * 1000 } - // MARK: Kind - public enum Kind : Int, CustomStringConvertible { + // 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) } + + // 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) } - public override func encode(with coder: NSCoder) { - super.encode(with: coder) - coder.encode(kind?.rawValue, forKey: "action") - } - - // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> TypingIndicator? { + // 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) 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 @@ -80,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 791b18c58..100edbefe 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -1,49 +1,67 @@ +// 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? 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() } - - internal init(timestamp: UInt64, author: String) { + // MARK: - Initialization + + public 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 + + 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) } - // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> UnsendRequest? { + 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 } let timestamp = unsendRequestProto.timestamp let author = unsendRequestProto.author 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 @@ -59,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+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index 8b0252fa6..e1eaad9bc 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -1,24 +1,68 @@ +// 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) + case openGroup( + roomToken: String, + server: String, + whisperTo: String? = nil, + whisperMods: Bool = false, + fileIds: [String]? = nil + ) + case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: 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, + fileIds: [String]? = nil + ) throws -> Message.Destination { + switch thread.variant { + case .contact: + 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: lookup.openGroupServer, + openGroupPublicKey: lookup.openGroupPublicKey, + blindedPublicKey: thread.id + ) + } + + 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 StorageError.objectNotFound + } + + 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/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index 2235c67b9..e33da9647 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -1,16 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionSnodeKit /// 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: 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 openGroupServerTimestamp: UInt64? + public var openGroupServerMessageId: UInt64? public var serverHash: String? public var ttl: UInt64 { 14 * 24 * 60 * 60 * 1000 } @@ -18,60 +21,416 @@ public class Message : NSObject, NSCoding { // NSObject/NSCoding conformance is public var shouldBeRetryable: 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, + 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.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 + + public class func fromProto(_ proto: SNProtoContent, sender: String) -> Self? { + preconditionFailure("fromProto(_:sender:) is abstract and must be overridden.") } - // MARK: Proto Conversion - public class func fromProto(_ proto: SNProtoContent) -> Self? { - preconditionFailure("fromProto(_:) is abstract and must be overridden.") + public func toProto(_ db: Database) -> SNProtoContent? { + preconditionFailure("toProto(_:) is abstract and must be overridden.") } - public func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { - preconditionFailure("toProto(using:) 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, + (try? ClosedGroup.exists(db, id: threadId)) == true, + 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 - let groupProto = SNProtoGroupContext.builder(id: thread.groupModel.groupId, type: .deliver) + let groupProto = SNProtoGroupContext.builder(id: legacyGroupId, type: .deliver) dataMessage.setGroup(try groupProto.build()) } +} + +// 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" - // MARK: General - @objc public func setSentTimestamp(_ sentTimestamp: UInt64) { - self.sentTimestamp = sentTimestamp + enum Variant: String, Codable { + case readReceipt + case typingIndicator + case closedGroupControlMessage + case dataExtractionNotification + case expirationTimerUpdate + case configurationMessage + case unsendRequest + case messageRequestResponse + case visibleMessage + case callMessage + + 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 + case is CallMessage: self = .callMessage + 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 + case .callMessage: return CallMessage.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) + case .callMessage: return try container.decode(CallMessage.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, + .callMessage + ] + + return prioritisedVariants + .reduce(nil) { prev, variant in + guard prev == nil else { return prev } + + return variant.messageType.fromProto(proto, sender: sender) + } + } + + 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 + ) 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 + } + + static func processReceivedOpenGroupMessage( + _ db: Database, + openGroupId: String, + openGroupServerPublicKey: String, + message: OpenGroupAPI.Message, + data: Data, + dependencies: SMKDependencies = SMKDependencies() + ) 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: 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))) + 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?, + serverHash: String?, + openGroupId: String? = nil, + openGroupMessageServerId: Int64? = nil, + openGroupServerPublicKey: String? = nil, + isOutgoing: Bool? = nil, + otherBlindedPublicKey: String? = nil, + handleClosedGroupKeyUpdateMessages: Bool, + dependencies: SMKDependencies = SMKDependencies() + ) throws -> ProcessedMessage? { + let (message, proto, threadId) = try MessageReceiver.parse( + db, + envelope: envelope, + serverExpirationTimestamp: serverExpirationTimestamp, + openGroupId: openGroupId, + openGroupMessageServerId: openGroupMessageServerId, + openGroupServerPublicKey: openGroupServerPublicKey, + isOutgoing: isOutgoing, + otherBlindedPublicKey: otherBlindedPublicKey, + dependencies: dependencies + ) + 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 { + func with(sentTimestamp: UInt64) -> Message { + self.sentTimestamp = sentTimestamp + return self } } diff --git a/SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift b/SessionMessagingKit/Messages/Signal/TSIncomingMessage+Conversion.swift deleted file mode 100644 index 6849fd50c..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 c965ff51b..000000000 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.h +++ /dev/null @@ -1,96 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@class TSContactThread; -@class TSGroupThread; - -@interface TSIncomingMessage : TSMessage - -@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 a6578df0c..000000000 --- a/SessionMessagingKit/Messages/Signal/TSIncomingMessage.m +++ /dev/null @@ -1,184 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSIncomingMessage.h" -#import "NSNotificationCenter+OWS.h" -#import "OWSDisappearingMessagesConfiguration.h" -#import "OWSDisappearingMessagesJob.h" -#import "OWSReadReceiptManager.h" -#import "TSAttachmentPointer.h" -#import "TSContactThread.h" -#import "TSDatabaseSecondaryIndexes.h" -#import "TSGroupThread.h" -#import -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface TSIncomingMessage () - -@property (nonatomic, getter=wasRead) BOOL read; - -@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]; -} - -#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 deleted file mode 100644 index 5e1385848..000000000 --- a/SessionMessagingKit/Messages/Signal/TSInfoMessage.h +++ /dev/null @@ -1,71 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface TSInfoMessage : TSMessage - -typedef NS_ENUM(NSInteger, TSInfoMessageType) { - TSInfoMessageTypeGroupCreated, - TSInfoMessageTypeGroupUpdated, - TSInfoMessageTypeGroupCurrentUserLeft, - TSInfoMessageTypeDisappearingMessagesUpdate, - TSInfoMessageTypeScreenshotNotification, - TSInfoMessageTypeMediaSavedNotification, - TSInfoMessageTypeCall, - TSInfoMessageTypeMessageRequestAccepted = 99 // Avoid conficts wit TSInfoMessageTypeCall -}; - -typedef NS_ENUM(NSInteger, TSInfoMessageCallState) { - TSInfoMessageCallStateIncoming, - TSInfoMessageCallStateOutgoing, - TSInfoMessageCallStateMissed, - TSInfoMessageCallStatePermissionDenied, - TSInfoMessageCallStateUnknown -}; - -@property (atomic, readonly) TSInfoMessageType messageType; -@property (atomic, nullable) NSString *customMessage; -@property (atomic, readonly, nullable) NSString *unregisteredRecipientId; -@property (atomic) TSInfoMessageCallState callState; - -- (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 a1fe6c753..000000000 --- a/SessionMessagingKit/Messages/Signal/TSInfoMessage.m +++ /dev/null @@ -1,187 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSInfoMessage.h" -#import "TSContactThread.h" -#import "SSKEnvironment.h" -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -NSUInteger TSInfoMessageSchemaVersion = 1; - -@interface TSInfoMessage () - -@property (nonatomic, getter=wasRead) BOOL read; - -@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; - _callState = TSInfoMessageCallStateUnknown; - _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 *)getCallMessagePreviewTextWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - TSThread *thread = [self threadWithTransaction:transaction]; - if ([thread isKindOfClass: [TSContactThread class]]) { - TSContactThread *contactThread = (TSContactThread *)thread; - NSString *sessionID = contactThread.contactSessionID; - NSString *name = [contactThread nameWithTransaction:transaction]; - if ([name isEqual:sessionID]) { - name = [NSString stringWithFormat:@"%@...%@", [sessionID substringToIndex:4], [sessionID substringFromIndex:sessionID.length - 4]]; - } - switch (_callState) { - case TSInfoMessageCallStateIncoming: - return [NSString stringWithFormat:NSLocalizedString(@"call_incoming", @""), name]; - case TSInfoMessageCallStateOutgoing: - return [NSString stringWithFormat:NSLocalizedString(@"call_outgoing", @""), name]; - case TSInfoMessageCallStateMissed: - case TSInfoMessageCallStatePermissionDenied: - return [NSString stringWithFormat:NSLocalizedString(@"call_missed", @""), name]; - default: - break; - } - } - return _customMessage; -} - -- (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 TSInfoMessageTypeCall: - return [self getCallMessagePreviewTextWithTransaction:transaction]; - case TSInfoMessageTypeMessageRequestAccepted: - return NSLocalizedString(@"MESSAGE_REQUESTS_ACCEPTED", @""); - default: - break; - } - - return @"Unknown Info Message Type"; -} - -#pragma mark - OWSReadTracking - -- (BOOL)shouldAffectUnreadCounts -{ - switch (_messageType) { - case TSInfoMessageTypeCall: - return YES; - default: - 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.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 f3522712d..000000000 --- a/SessionMessagingKit/Messages/Signal/TSInteraction.m +++ /dev/null @@ -1,274 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSInteraction.h" -#import "TSDatabaseSecondaryIndexes.h" -#import "TSThread.h" -#import "TSGroupThread.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 8739181c0..000000000 --- a/SessionMessagingKit/Messages/Signal/TSMessage.h +++ /dev/null @@ -1,93 +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; -@property (nonatomic) BOOL isCallMessage; - -- (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; - -- (void)updateCallMessageWithNewBody:(NSString *)newBody transaction:(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 0584aaf74..000000000 --- a/SessionMessagingKit/Messages/Signal/TSMessage.m +++ /dev/null @@ -1,456 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSMessage.h" -#import "AppContext.h" -#import "MIMETypeUtil.h" -#import "OWSDisappearingMessagesConfiguration.h" -#import "TSAttachment.h" -#import "TSAttachmentStream.h" -#import "TSQuotedMessage.h" -#import "TSThread.h" -#import -#import -#import -#import -#import "TSContactThread.h" -#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; - _isCallMessage = 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 = [OWSDisappearingMessagesConfiguration 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]; - }]; -} - -- (void)updateCallMessageWithNewBody:(NSString *)newBody transaction:(YapDatabaseReadWriteTransaction *)transaction -{ - if (!_isCallMessage) { return; } - [self applyChangeToSelfAndLatestCopy:transaction - changeBlock:^(TSMessage *message) { - [message setBody:newBody]; - }]; -} - -@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 1509af0f3..000000000 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage+Conversion.swift +++ /dev/null @@ -1,57 +0,0 @@ -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: OWSDisappearingMessagesConfiguration? - if let transaction = transaction { - disappearingMessagesConfigurationOrNil = OWSDisappearingMessagesConfiguration.fetch(uniqueId: thread.uniqueId!, transaction: transaction) - } else { - disappearingMessagesConfigurationOrNil = OWSDisappearingMessagesConfiguration.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 b61bada56..000000000 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.h +++ /dev/null @@ -1,227 +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; -@class SignalRecipient; - -@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 - -- (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 f2985104d..000000000 --- a/SessionMessagingKit/Messages/Signal/TSOutgoingMessage.m +++ /dev/null @@ -1,582 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -@import Foundation; - -#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" -#import "TSGroupThread.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; -@property (atomic, nullable) NSDictionary *recipientStateMap; - -@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/Signal/TypingIndicatorInteraction.swift b/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift deleted file mode 100644 index 4d72fc30b..000000000 --- a/SessionMessagingKit/Messages/Signal/TypingIndicatorInteraction.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@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/Messages/Visible Messages/VisibleMessage+Attachment.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift index f1454ff35..ed16977e4 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Attachment.swift @@ -1,10 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import CoreGraphics import SessionUtilitiesKit public extension VisibleMessage { - - @objc(SNAttachment) - class Attachment : NSObject, NSCoding { + class VMAttachment: Codable { + public enum Kind: String, Codable { + case voiceMessage, generic + } + public var fileName: String? public var contentType: String? public var key: Data? @@ -19,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 { - 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+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..894024106 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+LinkPreview.swift @@ -1,54 +1,55 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit public extension VisibleMessage { + struct VMLinkPreview: Codable { + public let title: String? + public let url: String? + public let attachmentId: String? - @objc(SNLinkPreview) - class LinkPreview : NSObject, NSCoding { - public var title: String? - public var url: String? - public var attachmentID: String? + public var isValid: Bool { title != nil && url != nil && attachmentId != nil } + + // MARK: - Initialization - 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 + self.attachmentId = attachmentId } + + // MARK: - Proto Conversion - 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") - } - - 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? { 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: Attachment = try? Attachment.fetchOne(db, id: attachmentId), + let attachmentProto = attachment.buildProto() + { linkPreviewProto.setImage(attachmentProto) } + do { return try linkPreviewProto.build() } catch { @@ -57,15 +58,28 @@ 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") ) """ } } } + +// MARK: - Database Type Conversion + +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 678eeb04a..87e92a555 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+OpenGroupInvitation.swift @@ -1,32 +1,28 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit public extension VisibleMessage { + struct VMOpenGroupInvitation: Codable { + public let name: String? + public let url: String? + + // MARK: - Initialization - @objc(SNOpenGroupInvitation) - class OpenGroupInvitation : NSObject, 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 } - } + // MARK: - Proto Conversion - public func encode(with coder: NSCoder) { - coder.encode(name, forKey: "name") - coder.encode(url, forKey: "url") - } - - 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? { @@ -43,8 +39,9 @@ public extension VisibleMessage { } } - // MARK: Description - public override var description: String { + // MARK: - Description + + public var description: String { """ OpenGroupInvitation( name: \(name ?? "null"), @@ -54,3 +51,16 @@ public extension VisibleMessage { } } } + +// MARK: - Database Type Conversion + +public extension VisibleMessage.VMOpenGroupInvitation { + static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.VMOpenGroupInvitation? { + guard let name: String = linkPreview.title else { return nil } + + 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 d9e82f7e5..a45766c36 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -1,40 +1,37 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import SessionUtilitiesKit public extension VisibleMessage { + struct VMProfile: Codable { + public let displayName: String? + public let profileKey: Data? + public let profilePictureUrl: String? + + // MARK: - Initialization - @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) { + 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) } - 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 } - } + // MARK: - Proto Conversion - 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 static func fromProto(_ proto: SNProtoDataMessage) -> VMProfile? { + guard + let profileProto = proto.profile, + let displayName = profileProto.displayName + else { return nil } + + return VMProfile( + displayName: displayName, + profileKey: proto.profileKey, + profilePictureUrl: profileProto.profilePicture + ) } public func toProto() -> SNProtoDataMessage? { @@ -45,9 +42,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()) @@ -59,14 +57,25 @@ 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") ) """ } } } + +// MARK: - Conversion + +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 2a0f9bf93..fdd50732c 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -1,58 +1,51 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import SessionUtilitiesKit public extension VisibleMessage { - @objc(SNQuote) - class Quote : NSObject, NSCoding { - public var timestamp: UInt64? - public var publicKey: String? - public var text: String? - public var attachmentID: String? + struct VMQuote: 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 + self.attachmentId = attachmentId } + + // MARK: - Proto Conversion - 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") - } - - 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? { 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 +54,12 @@ public extension VisibleMessage { } } - private func addAttachmentsIfNeeded(to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder, using transaction: YapDatabaseReadWriteTransaction) { - guard let attachmentID = attachmentID else { return } - guard let stream = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction) as? TSAttachmentStream, stream.isUploaded else { + private func addAttachmentsIfNeeded(_ db: Database, to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder) { + guard let attachmentId = attachmentId else { return } + guard + 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.") #else @@ -71,9 +67,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) @@ -84,16 +80,30 @@ 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") ) """ } } } + +// MARK: - Database Type Conversion + +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 + ) + } +} diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 21c1a41be..698baa732 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -1,111 +1,176 @@ +// 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. 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: Contact? - @objc public var profile: Profile? - @objc public var openGroupInvitation: OpenGroupInvitation? + public let text: String? + public var attachmentIds: [String] + public let quote: VMQuote? + public let linkPreview: VMLinkPreview? + public var profile: VMProfile? + public let openGroupInvitation: VMOpenGroupInvitation? 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 } - // TODO: Contact - if let profile = coder.decodeObject(forKey: "profile") as! Profile? { self.profile = profile } - if let openGroupInvitation = coder.decodeObject(forKey: "openGroupInvitation") as! OpenGroupInvitation? { self.openGroupInvitation = openGroupInvitation } + + // MARK: - Initialization + + public init( + sentTimestamp: UInt64? = nil, + recipient: String? = nil, + groupPublicKey: String? = nil, + syncTarget: String? = nil, + text: String?, + attachmentIds: [String] = [], + quote: VMQuote? = nil, + linkPreview: VMLinkPreview? = nil, + profile: VMProfile? = nil, + openGroupInvitation: VMOpenGroupInvitation? = 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 { + 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(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) + } + + 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) } - 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") - // TODO: Contact - coder.encode(profile, forKey: "profile") - coder.encode(openGroupInvitation, forKey: "openGroupInvitation") - } - - // MARK: Proto Conversion - public override class func fromProto(_ proto: SNProtoContent) -> VisibleMessage? { + // 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 { VMQuote.fromProto($0) }, + linkPreview: dataMessage.preview.first.map { VMLinkPreview.fromProto($0) }, + profile: VMProfile.fromProto(dataMessage), + openGroupInvitation: dataMessage.openGroupInvitation.map { VMOpenGroupInvitation.fromProto($0) } + ) } - public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? { + public override func toProto(_ db: Database) -> SNProtoContent? { let proto = SNProtoContent.builder() - var attachmentIDs = self.attachmentIDs + 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 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 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: [Attachment]? = try? 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) } + 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 @@ -124,18 +189,57 @@ 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") ) """ } } + +// MARK: - Database Type Conversion + +public extension VisibleMessage { + static func from(_ db: Database, interaction: Interaction) -> VisibleMessage { + let linkPreview: LinkPreview? = try? interaction.linkPreview.fetchOne(db) + + 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 { VMQuote.from(db, quote: $0) }, + linkPreview: linkPreview + .map { linkPreview in + guard linkPreview.variant == .standard else { return nil } + + return VMLinkPreview.from(db, linkPreview: linkPreview) + }, + 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 VMOpenGroupInvitation.from( + db, + linkPreview: linkPreview + ) + } + ) + } +} diff --git a/SessionMessagingKit/Meta/SessionMessagingKit.h b/SessionMessagingKit/Meta/SessionMessagingKit.h index da0a86ab6..a0fda3d4a 100644 --- a/SessionMessagingKit/Meta/SessionMessagingKit.h +++ b/SessionMessagingKit/Meta/SessionMessagingKit.h @@ -4,49 +4,7 @@ FOUNDATION_EXPORT double SessionMessagingKitVersionNumber; 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 -#import -#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/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift new file mode 100644 index 000000000..fb3ac4e41 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift @@ -0,0 +1,174 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionUtilitiesKit +import SessionSnodeKit + +extension OpenGroupAPI { + // MARK: - BatchSubRequest + + 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]? + + /// 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()) + + // 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 + } + } + + 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) + } + } + + // 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] + + /// 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 { + let request: Request + let responseType: Codable.Type + + var endpoint: Endpoint { request.endpoint } + + 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) + } + } + + // MARK: - BatchRequest + + typealias BatchRequest = [BatchSubRequest] + typealias BatchResponseTypes = [Codable.Type] + 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 != NoResponse.self && !(T.self is ExpressibleByNilLiteral.Type)) + ) + } +} + +// 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 { + 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: 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 } + guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { + throw HTTP.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 HTTP.Error.parsingFailed } + + do { + return try zip(dataArray, types) + .map { data, type in try type.decoded(from: data, using: dependencies) } + .map { data in (responseInfo, data) } + } + catch { + throw HTTP.Error.parsingFailed + } + } + } +} diff --git a/SessionMessagingKit/Open Groups/Models/Capabilities.swift b/SessionMessagingKit/Open Groups/Models/Capabilities.swift new file mode 100644 index 000000000..2947214b8 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/Capabilities.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public struct Capabilities: Codable, Equatable { + public let capabilities: [Capability.Variant] + public let missing: [Capability.Variant]? + + // MARK: - Initialization + + public init(capabilities: [Capability.Variant], missing: [Capability.Variant]? = nil) { + self.capabilities = capabilities + self.missing = missing + } + } +} diff --git a/SessionMessagingKit/Open Groups/Models/DirectMessage.swift b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift new file mode 100644 index 000000000..f2e7421ef --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/DirectMessage.swift @@ -0,0 +1,34 @@ +// 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 recipient + case posted = "posted_at" + case expires = "expires_at" + 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 + + /// 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 + + /// The encrypted message body + public let base64EncodedMessage: String + } +} diff --git a/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift new file mode 100644 index 000000000..e8f1f7a8e --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/PinnedMessage.swift @@ -0,0 +1,22 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public struct PinnedMessage: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case id + case pinnedAt = "pinned_at" + 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 new file mode 100644 index 000000000..f1a2f32d6 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/Room.swift @@ -0,0 +1,191 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public struct Room: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case token + case name + case roomDescription = "description" + 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 + 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 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 + + /// 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 roomDescription: 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: String? + + /// 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 + + /// 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 + + /// 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 + + /// 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? + } +} + +// MARK: - Decoding + +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), + 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), + + activeUsers: try container.decode(Int64.self, forKey: .activeUsers), + activeUsersCutoff: try container.decode(Int64.self, forKey: .activeUsersCutoff), + imageId: maybeImageId, + 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), + 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), + 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) + ) + } +} diff --git a/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift new file mode 100644 index 000000000..de43a68a6 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/RoomPollInfo.swift @@ -0,0 +1,143 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + /// This only contains ephemeral data + public struct RoomPollInfo: Codable { + enum CodingKeys: String, CodingKey { + case token + case activeUsers = "active_users" + + case admin + case globalAdmin = "global_admin" + + case moderator + case globalModerator = "global_moderator" + + case read + case defaultRead = "default_read" + case defaultAccessible = "default_accessible" + case write + case defaultWrite = "default_write" + case upload + case defaultUpload = "default_upload" + + case details + } + + /// The room token as used in a URL, e.g. "sudoku" + 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) + /// + /// 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 + + /// 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 + + /// 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? + + /// 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? + + /// 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? + } +} + +// MARK: - Convenience + +extension OpenGroupAPI.RoomPollInfo { + init(room: OpenGroupAPI.Room) { + self.init( + token: room.token, + activeUsers: room.activeUsers, + admin: room.admin, + globalAdmin: room.globalAdmin, + moderator: room.moderator, + globalModerator: room.globalModerator, + read: room.read, + defaultRead: room.defaultRead, + defaultAccessible: room.defaultAccessible, + write: room.write, + defaultWrite: room.defaultWrite, + upload: room.upload, + defaultUpload: room.defaultUpload, + details: room + ) + } +} + +// 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/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift new file mode 100644 index 000000000..f566565f7 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -0,0 +1,93 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +extension OpenGroupAPI { + public struct Message: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case id + case sender = "session_id" + case posted + case edited + case deleted + 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 deleted: Bool? + 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 OpenGroupAPI.Message { + public init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + let maybeSender: String? = try? container.decode(String.self, forKey: .sender) + let maybeBase64EncodedData: String? = try? container.decode(String.self, forKey: .base64EncodedData) + let maybeBase64EncodedSignature: String? = try? container.decode(String.self, forKey: .base64EncodedSignature) + + // 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 HTTP.Error.parsingFailed + } + guard let dependencies: SMKDependencies = decoder.userInfo[Dependencies.userInfoKey] as? SMKDependencies else { + throw HTTP.Error.parsingFailed + } + + // Verify the signature based on the SessionId.Prefix type + let publicKey: Data = Data(hex: sender.removingIdPrefixIfNeeded()) + + 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.") + 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 HTTP.Error.parsingFailed + } + + case .none: + SNLog("Ignoring message with invalid sender.") + throw HTTP.Error.parsingFailed + } + } + + self = OpenGroupAPI.Message( + id: try container.decode(Int64.self, forKey: .id), + sender: try? container.decode(String.self, forKey: .sender), + posted: try container.decode(TimeInterval.self, forKey: .posted), + edited: try? container.decode(TimeInterval.self, forKey: .edited), + deleted: try? container.decode(Bool.self, forKey: .deleted), + 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/SendDirectMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift new file mode 100644 index 000000000..19df350f9 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/SendDirectMessageRequest.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public struct SendDirectMessageRequest: Codable { + let message: Data + + // MARK: - Encodable + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(message.base64EncodedString(), forKey: .message) + } + } +} diff --git a/SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift b/SessionMessagingKit/Open Groups/Models/SendDirectMessageResponse.swift new file mode 100644 index 000000000..a8e998f8a --- /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, Equatable { + 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/Models/SendMessageRequest.swift b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift new file mode 100644 index 000000000..98007184a --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/SendMessageRequest.swift @@ -0,0 +1,78 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public struct SendMessageRequest: Codable { + enum CodingKeys: String, CodingKey { + case data + case signature + case whisperTo = "whisper_to" + case whisperMods = "whisper_mods" + 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? + + /// 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 + /// + /// **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 + + init( + data: Data, + signature: Data, + whisperTo: String? = nil, + whisperMods: Bool? = nil, + fileIds: [String]? = 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.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 new file mode 100644 index 000000000..f18f72a63 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UpdateMessageRequest.swift @@ -0,0 +1,35 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 { + 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/UserBanRequest.swift b/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift new file mode 100644 index 000000000..caff1a17d --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UserBanRequest.swift @@ -0,0 +1,31 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 new file mode 100644 index 000000000..ece21d2ba --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UserModeratorRequest.swift @@ -0,0 +1,69 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +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. + /// + /// 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/UserUnbanRequest.swift b/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift new file mode 100644 index 000000000..b0e8a2ab9 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/UserUnbanRequest.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 new file mode 100644 index 000000000..60011301e --- /dev/null +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -0,0 +1,1308 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import Sodium +import Curve25519Kit +import SessionSnodeKit +import SessionUtilitiesKit + +public enum OpenGroupAPI { + // MARK: - Settings + + 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 + + // MARK: - Batching & Polling + + /// 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) + /// - Inbox for the server + /// - Outbox for the server + public static func poll( + _ db: Database, + server: String, + hasPerformedInitialPoll: Bool, + timeSinceLastPoll: TimeInterval, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { + 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] = [ + BatchRequestInfo( + request: Request( + server: server, + endpoint: .capabilities + ), + responseType: Capabilities.self + ) + ] + .appending( + // 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 + let shouldRetrieveRecentMessages: Bool = ( + 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 + !hasPerformedInitialPoll && + timeSinceLastPoll > OpenGroupAPI.Poller.maxInactivityPeriod + ) + ) + + return [ + BatchRequestInfo( + request: Request( + server: server, + endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates) + ), + responseType: RoomPollInfo.self + ), + BatchRequestInfo( + request: Request( + server: server, + endpoint: (shouldRetrieveRecentMessages ? + .roomMessagesRecent(openGroup.roomToken) : + .roomMessagesSince(openGroup.roomToken, seqNo: openGroup.sequenceNumber) + ) + ), + responseType: [Failable].self + ) + ] + } + ) + .appending( + 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 + ), + + // 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 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 + /// + /// 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( + _ db: Database, + server: String, + requests: [BatchRequestInfoType], + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { + let requestBody: BatchRequest = requests.map { $0.toSubRequest() } + let responseTypes = requests.map { $0.responseType } + + 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() + .reduce(into: [:]) { prev, next in + prev[requests[next.offset].endpoint] = next.element + } + } + } + + /// 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)." + /// + /// 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( + _ db: Database, + server: String, + requests: [BatchRequestInfoType], + authenticated: Bool = true, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { + let requestBody: BatchRequest = requests.map { $0.toSubRequest() } + let responseTypes = requests.map { $0.responseType } + + return OpenGroupAPI + .send( + db, + request: Request( + method: .post, + server: server, + endpoint: Endpoint.sequence, + body: requestBody + ), + authenticated: authenticated, + using: dependencies + ) + .decoded(as: responseTypes, on: OpenGroupAPI.workQueue, using: dependencies) + .map { result in + result.enumerated() + .reduce(into: [:]) { prev, next in + prev[requests[next.offset].endpoint] = next.element + } + } + } + + // 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( + _ db: Database, + server: String, + authenticated: Bool = true, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .capabilities + ), + authenticated: authenticated, + using: dependencies + ) + .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, using: dependencies) + } + + // 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( + _ db: Database, + server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, [Room])> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .rooms + ), + using: dependencies + ) + .decoded(as: [Room].self, on: OpenGroupAPI.workQueue, using: dependencies) + } + + /// 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( + _ db: Database, + for roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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) + } + + /// 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-built `poll()` method instead") + public static func roomPollInfo( + _ db: Database, + lastUpdated: Int64, + for roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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, + authenticated: Bool = true, + 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) + 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 OpenGroupAPI + .sequence( + db, + server: server, + requests: requestResponseType, + authenticated: authenticated, + 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) } + let maybeRoomResponse: (OnionRequestResponseInfoType, Codable?)? = response + .first(where: { key, _ in + switch key { + case .room: return true + default: return false + } + }) + .map { _, value in value } + let maybeRoom: (info: OnionRequestResponseInfoType, data: Room?)? = maybeRoomResponse + .map { info, data in (info, (data as? BatchSubResponse)?.body) } + + 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 ( + (capabilitiesInfo, capabilities), + (roomInfo, room) + ) + } + } + + /// 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 + public static func send( + _ db: Database, + plaintext: Data, + to roomToken: String, + on server: String, + whisperTo: String?, + whisperMods: Bool, + fileIds: [String]?, + 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: OpenGroupAPIError.signingFailed) + } + + 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) + } + + /// Returns a single message by ID + public static func message( + _ db: Database, + id: Int64, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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) + } + + /// 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( + _ db: Database, + id: Int64, + plaintext: Data, + fileIds: [Int64]?, + in roomToken: String, + on server: String, + 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: OpenGroupAPIError.signingFailed) + } + + 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( + _ db: Database, + id: Int64, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + 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( + _ db: Database, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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) + } + + /// **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-built `poll()` method instead") + public static func messagesBefore( + _ db: Database, + messageId: Int64, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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) + } + + /// **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-built `poll()` method instead") + public static func messagesSince( + _ db: Database, + seqNo: Int64, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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) + } + + /// 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( + _ db: Database, + sessionId: String, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + return OpenGroupAPI + .send( + db, + request: Request( + method: .delete, + server: server, + endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) + ), + using: dependencies + ) + } + + // 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( + _ db: Database, + id: Int64, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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( + _ db: Database, + id: Int64, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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( + _ db: Database, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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( + _ db: Database, + bytes: [UInt8], + fileName: String? = nil, + to roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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( + _ db: Database, + fileId: String, + from roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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 } + + return (responseInfo, data) + } + } + + // 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.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( + _ db: Database, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .inbox + ), + 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 + /// + /// **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-built `poll()` method instead") + public static func inboxSince( + _ db: Database, + id: Int64, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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( + _ db: Database, + ciphertext: Data, + toInboxFor blindedSessionId: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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) + } + + /// 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-built `poll()` method instead") + public static func outbox( + _ db: Database, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { + return OpenGroupAPI + .send( + db, + request: Request( + server: server, + endpoint: .outbox + ), + 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 + /// + /// **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-built `poll()` method instead") + public static func outboxSince( + _ db: Database, + id: Int64, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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) + } + + // MARK: - Users + + /// 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( + _ db: Database, + sessionId: String, + for timeout: TimeInterval? = nil, + from roomTokens: [String]? = nil, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + 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 + /// + /// 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( + _ db: Database, + sessionId: String, + from roomTokens: [String]?, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + 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 + /// + /// 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( + _ db: Database, + sessionId: String, + moderator: Bool? = nil, + admin: Bool? = nil, + visible: Bool, + for roomTokens: [String]?, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { + return Promise(error: HTTP.Error.generic) + } + + 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( + _ db: Database, + sessionId: String, + in roomToken: String, + on server: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<[OnionRequestResponseInfoType]> { + let banRequestBody: UserBanRequest = UserBanRequest( + rooms: [roomToken], + global: nil, + timeout: nil + ) + + // Generate the requests + let requestResponseType: [BatchRequestInfoType] = [ + BatchRequestInfo( + request: Request( + method: .post, + server: server, + endpoint: .userBan(sessionId), + body: banRequestBody + ) + ), + BatchRequestInfo( + request: Request( + method: .delete, + server: server, + endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) + ) + ) + ] + + 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( + _ db: Database, + messageBytes: Bytes, + for serverName: String, + fallbackSigningType signingType: SessionId.Prefix, + using dependencies: SMKDependencies = SMKDependencies() + ) -> (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 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 capabilities.contains(.blind) { + guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + return nil + } + + guard let signatureResult: Bytes = dependencies.sodium.sogsSignature(message: messageBytes, secretKey: userEdKeyPair.secretKey, blindedSecretKey: blindedKeyPair.secretKey, blindedPublicKey: blindedKeyPair.publicKey) else { + return nil + } + + return ( + publicKey: SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString, + signature: signatureResult + ) + } + + // 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: 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).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( + _ db: Database, + request: URLRequest, + for serverName: String, + with serverPublicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> 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(dependencies.date.timeIntervalSince1970)) + 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 } + + /// Get a hash of any body content + let bodyHash: Bytes? = { + guard let body: Data = request.httpBody else { return nil } + + return dependencies.genericHash.hash(message: body.bytes, outputLength: 64) + }() + + /// 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 messageBytes: Bytes = serverPublicKeyData.bytes + .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(db, messageBytes: messageBytes, for: serverName, fallbackSigningType: .unblinded, using: dependencies) else { + return nil + } + + updatedRequest.allHTTPHeaderFields = (request.allHTTPHeaderFields ?? [:]) + .updated(with: [ + Header.sogsPubKey.rawValue: signResult.publicKey, + Header.sogsTimestamp.rawValue: "\(timestamp)", + Header.sogsNonce.rawValue: nonce.base64EncodedString(), + Header.sogsSignature.rawValue: signResult.signature.toBase64() + ]) + + return updatedRequest + } + + // MARK: - Convenience + + private static func send( + _ db: Database, + request: Request, + authenticated: Bool = true, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + let urlRequest: URLRequest + + do { + urlRequest = try request.generateUrlRequest() + } + catch { + return Promise(error: error) + } + + 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: OpenGroupAPIError.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: OpenGroupAPIError.signingFailed) + } + + return dependencies.onionApi.sendOnionRequest(signedRequest, to: request.server, with: publicKey) + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift deleted file mode 100644 index 11686edc8..000000000 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2+ObjC.swift +++ /dev/null @@ -1,19 +0,0 @@ -import PromiseKit - -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)) - } - - @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(getDefaultRoomsIfNeeded) - public static func objc_getDefaultRoomsIfNeeded() { - getDefaultRoomsIfNeeded() - } -} diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift b/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift deleted file mode 100644 index 97532799d..000000000 --- a/SessionMessagingKit/Open Groups/OpenGroupAPIV2.swift +++ /dev/null @@ -1,531 +0,0 @@ -import PromiseKit -import SessionSnodeKit - -@objc(SNOpenGroupAPIV2) -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 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 static let legacyDefaultServerDNS = "open.getsession.org" - 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: 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) - } - } - - // 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") - 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) - } - } else { - 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] = [:] - 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) - } - } - } - return when(fulfilled: promises) - } - } - } - - // MARK: Authorization - private static func getAuthToken(for room: String, on server: String) -> Promise { - let storage = SNMessagingKitConfiguration.shared.storage - if let authToken = storage.getAuthToken(for: room, on: server) { - return Promise.value(authToken) - } else { - if let authTokenPromise = authTokenPromises.wrappedValue["\(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.mutate{ $0["\(server).\(room)"] = nil } - }.catch(on: OpenGroupAPIV2.workQueue) { _ in - authTokenPromises.mutate{ $0["\(server).\(room)"] = nil } - } - authTokenPromises.mutate{ $0["\(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 - } - 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) - 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") - 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 - 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 - } - } - - 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 - } - } - - // 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 } - 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 - 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) - } - } - - private static func parseMessages(from json: JSON, for room: String, on server: String) throws -> Promise<[OpenGroupMessageV2]> { - 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.removing05PrefixIfNeeded()) - 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 - 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 - } else { - return Promise.value(messages) - } - } - - // 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)") - 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) - } - } - - private static func parseDeletions(from rawDeletions: [JSON], for room: String, on server: String) -> Promise<[Deletion]> { - 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 - 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 - } else { - return Promise.value(deletions) - } - } - - // 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)] - } - 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) - 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) - 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)") - 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 - } - - // MARK: General - - @discardableResult public static func getDefaultRoomsIfNeeded() -> Promise<[OpenGroupAPIV2.Info]> { - if let existingPromise: Promise<[OpenGroupAPIV2.Info]> = defaultRoomsPromise { - return existingPromise - } - - let (promise, seal) = Promise<[OpenGroupAPIV2.Info]>.pending() - - Storage.shared.write(with: { transaction in - Storage.shared.setOpenGroupPublicKey(for: defaultServer, to: defaultServerPublicKey, using: transaction) - }, completion: { - let internalPromise: Promise<[OpenGroupAPIV2.Info]> = attempt(maxRetryCount: 8, recoveringOn: DispatchQueue.main) { - OpenGroupAPIV2.getAllRooms(from: defaultServer) - } - - internalPromise - .done(on: OpenGroupAPIV2.workQueue) { items in - items.forEach { getGroupImage(for: $0.id, on: defaultServer).retainUntilComplete() } - seal.fulfill(items) - } - .retainUntilComplete() - - internalPromise - .catch(on: OpenGroupAPIV2.workQueue) { error in - OpenGroupAPIV2.defaultRoomsPromise = nil - seal.reject(error) - } - }) - - defaultRoomsPromise = promise - return 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 - } - let imageID = json["image_id"] as? String - return Info(id: id, name: name, imageID: imageID) - } - 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) - } - return memberCount - } - } - - public static func getGroupImage(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 = UserDefaults.standard[.lastOpenGroupImageUpdate] - let now = Date() - let timeSinceLastUpdate = 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 - return promise - } - } -} diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift new file mode 100644 index 000000000..0c73af2cc --- /dev/null +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -0,0 +1,1019 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +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 hasPerformedInitialPoll: [String: Bool] { get set } + var timeSinceLastPoll: [String: TimeInterval] { get set } + + func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval +} + +// MARK: - OpenGroupManager + +@objc(SNOpenGroupManager) +public final class OpenGroupManager: NSObject { + // MARK: - Cache + + public class Cache: OGMCacheType { + public var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? + public var groupImagePromises: [String: Promise] = [:] + + public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server + public var isPolling: Bool = false + + /// Server URL to value + public var hasPerformedInitialPoll: [String: Bool] = [:] + public var timeSinceLastPoll: [String: TimeInterval] = [:] + + fileprivate var _timeSinceLastOpen: TimeInterval? + public func getTimeSinceLastOpen(using dependencies: 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() + + /// 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: OGMDependencies = OGMDependencies()) { + guard !dependencies.cache.isPolling else { return } + + 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 + try OpenGroup + .select(.server) + .filter(OpenGroup.Columns.isActive == true) + .filter(OpenGroup.Columns.roomToken != "") + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + } + .defaulting(to: []) + + dependencies.mutableCache.mutate { cache in + cache.isPolling = true + cache.pollers = servers + .reduce(into: [:]) { result, server in + 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) } + } + } + + public func stopPolling(using dependencies: OGMDependencies = OGMDependencies()) { + dependencies.mutableCache.mutate { + $0.pollers.forEach { (_, openGroupPoller) in openGroupPoller.stop() } + $0.pollers.removeAll() + $0.isPolling = false + } + } + + // 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 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)", + "http://\(serverHost)\(serverPort)", + "https://\(serverHost)\(serverPort)" + ]) + + // 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("https://\(defaultServerHost)") + 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 + 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 + (try? SessionThread + .exists( + db, + id: OpenGroup.idFor(roomToken: roomToken, server: serverName) + )) + .defaulting(to: false) + }) + + return hasExistingThread + } + + 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(db, roomToken: roomToken, server: server, publicKey: publicKey, dependencies: dependencies) { + SNLog("Ignoring join open group attempt (already joined), user initiated: \(!isConfigMessage)") + return Promise.value(()) + } + + // Store the open group information + 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 + _ = 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 + .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: targetServer)) + .updateAll( + db, + OpenGroup.Columns.isActive.set(to: true), + OpenGroup.Columns.sequenceNumber.set(to: 0) + ) + + let (promise, seal) = Promise.pending() + + // Note: We don't do this after the db commit as it can fail (resulting in endless loading) + 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: targetServer, + authenticated: false, + using: dependencies + ) + } + .done(on: OpenGroupAPI.workQueue) { response in + dependencies.storage.write { db in + // Store the capabilities first + OpenGroupManager.handleCapabilities( + db, + capabilities: response.capabilities.data, + on: targetServer + ) + + // Then the room + try OpenGroupManager.handlePollInfo( + db, + pollInfo: OpenGroupAPI.RoomPollInfo(room: response.room.data), + publicKey: publicKey, + for: roomToken, + on: targetServer, + dependencies: dependencies + ) { + seal.fulfill(()) + } + } + } + .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in + SNLog("Failed to join open group.") + seal.reject(error) + } + .retainUntilComplete() + } + + return promise + } + + public func delete(_ db: Database, openGroupId: String, dependencies: OGMDependencies = OGMDependencies()) { + let server: String? = try? OpenGroup + .select(.server) + .filter(id: openGroupId) + .asRequest(of: String.self) + .fetchOne(db) + + // Stop the poller if needed + // + // 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?.lowercased()) + .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.isActive) + .fetchCount(db)) + .defaulting(to: 1) + + if numActiveRooms == 1, let server: String = server?.lowercased() { + let poller = dependencies.cache.pollers[server] + poller?.stop() + dependencies.mutableCache.mutate { $0.pollers[server] = nil } + } + + // 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) + } + + // MARK: - Response Processing + + internal static func handleCapabilities( + _ db: Database, + capabilities: OpenGroupAPI.Capabilities, + on server: String + ) { + // Remove old capabilities first + _ = try? Capability + .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.lowercased(), + variant: capability, + isMissing: false + ) + .saved(db) + } + capabilities.missing?.forEach { capability in + _ = try? Capability( + openGroupServer: server.lowercased(), + variant: capability, + isMissing: true + ) + .saved(db) + } + } + + internal static func handlePollInfo( + _ db: Database, + pollInfo: OpenGroupAPI.RoomPollInfo, + publicKey maybePublicKey: String?, + for roomToken: String, + on server: String, + waitForImageToComplete: Bool = false, + dependencies: OGMDependencies = OGMDependencies(), + completion: (() -> ())? = nil + ) throws { + // Create the open group model and get or create the thread + let threadId: String = OpenGroup.idFor(roomToken: roomToken, server: server) + + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } + + // 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.imageId.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 } + ) + + // 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) + } + } + + db.afterNextTransactionCommit { db in + // Start the poller if needed + if dependencies.cache.pollers[server.lowercased()] == nil { + dependencies.mutableCache.mutate { + $0.pollers[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased()) + $0.pollers[server.lowercased()]?.startIfNeeded(using: dependencies) + } + } + + /// Start downloading the room image (if we don't have one or it's been updated) + if + let imageId: String = pollInfo.details?.imageId, + ( + openGroup.imageData == nil || + openGroup.imageId != imageId + ) + { + OpenGroupManager.roomImage(db, fileId: imageId, for: roomToken, on: server, using: dependencies) + .done { data in + dependencies.storage.write { db in + _ = try OpenGroup + .filter(id: threadId) + .updateAll(db, OpenGroup.Columns.imageData.set(to: data)) + + 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?() + } + } + + internal static func handleMessages( + _ db: Database, + messages: [OpenGroupAPI.Message], + for roomToken: String, + on server: String, + isBackgroundPoll: Bool, + 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 + 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 + .filter { $0.deleted != true } + .sorted { lhs, rhs in lhs.id < rhs.id } + var messageServerIdsToRemove: [Int64] = messages + .filter { $0.deleted == true } + .map { $0.id } + let seqNo: Int64? = sortedMessages.map { $0.seqNo }.max() + + // Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId') + if let seqNo: Int64 = seqNo { + _ = 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 { + // 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( + 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 { + 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 } + + _ = try? Interaction + .filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId)) + .deleteAll(db) + } + + internal static func handleDirectMessages( + _ db: Database, + messages: [OpenGroupAPI.DirectMessage], + fromOutbox: Bool, + on server: String, + isBackgroundPoll: Bool, + 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 openGroup: OpenGroup = try? OpenGroup.filter(OpenGroup.Columns.server == server.lowercased()).fetchOne(db) 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[sortedMessages.count - 1].id + var lookupCache: [String: BlindedIdLookup] = [:] // Only want this cache to exist for the current loop + + // Update the 'latestMessageId' value + if fromOutbox { + _ = try? OpenGroup + .filter(OpenGroup.Columns.server == server.lowercased()) + .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: latestMessageId)) + } + else { + _ = try? OpenGroup + .filter(OpenGroup.Columns.server == server.lowercased()) + .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: latestMessageId)) + } + + // Process the messages + sortedMessages.forEach { message in + guard let messageData = Data(base64Encoded: message.base64EncodedMessage) else { + SNLog("Couldn't receive inbox message.") + return + } + + do { + let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupDirectMessage( + db, + openGroupServerPublicKey: openGroup.publicKey, + message: message, + data: messageData, + isOutgoing: fromOutbox, + otherBlindedPublicKey: (fromOutbox ? message.recipient : message.sender), + dependencies: dependencies + ) + + // 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 { + let syncTarget: String = (lookup.sessionId ?? message.recipient) + + switch processedMessage?.messageInfo.variant { + case .visibleMessage: + (processedMessage?.messageInfo.message as? VisibleMessage)?.syncTarget = syncTarget + + case .expirationTimerUpdate: + (processedMessage?.messageInfo.message as? ExpirationTimerUpdate)?.syncTarget = syncTarget + + default: break + } + } + + 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 { + 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).") + } + } + } + } + + // 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 roomToken: String?, + on server: String?, + using dependencies: OGMDependencies = OGMDependencies() + ) -> Bool { + guard let roomToken: String = roomToken, let server: String = server else { return false } + + let groupId: String = OpenGroup.idFor(roomToken: roomToken, server: server) + let targetRoles: [GroupMember.Role] = [.moderator, .admin] + + 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) + + // If the publicKey provided matches a mod or admin directly then just return immediately + if isDirectModOrAdmin { return true } + + // Otherwise we need to check if it's a variant of the current users key and if so we want + // to check if any of those have mod/admin entries + guard let sessionId: SessionId = SessionId(from: publicKey) else { return false } + + // 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, dependencies: dependencies) + + 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]> { + // Note: If we already have a 'defaultRoomsPromise' then there is no need to get it again + if let existingPromise: Promise<[OpenGroupAPI.Room]> = dependencies.cache.defaultRoomsPromise { + return existingPromise + } + + 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.capabilitiesAndRooms( + db, + on: OpenGroupAPI.defaultServer, + authenticated: false, + using: dependencies + ) + } + } + .done(on: OpenGroupAPI.workQueue) { response in + dependencies.storage.writeAsync { db in + // 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) + do { + _ = try OpenGroup( + server: OpenGroupAPI.defaultServer, + roomToken: room.token, + publicKey: OpenGroupAPI.defaultServerPublicKey, + isActive: false, + name: room.name, + roomDescription: room.roomDescription, + imageId: room.imageId, + imageData: nil, + userCount: room.activeUsers, + infoUpdates: room.infoUpdates, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ) + .inserted(db) + } + catch {} + + guard let imageId: String = 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(response.rooms.data) + } + .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 + } + + return promise + } + + public static func roomImage( + _ db: Database, + fileId: String, + for roomToken: String, + on server: String, + 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 + // 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 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 + server.lowercased() == 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[threadId] { + return promise + } + + let (promise, seal) = Promise.pending() + + // Trigger the download on a background queue + DispatchQueue.global(qos: .background).async { + dependencies.storage + .read { db in + OpenGroupAPI + .downloadFile( + db, + fileId: fileId, + from: roomToken, + on: server, + using: dependencies + ) + } + .done { _, imageData in + if server.lowercased() == 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) } + .retainUntilComplete() + } + + dependencies.mutableCache.mutate { cache in + cache.groupImagePromises[threadId] = promise + } + + return promise + } + + 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") + + // If there is no scheme then the host is included in the path (so handle that case) + 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: "=") + guard !room.isEmpty && !room.contains("/"), queryParts.count == 2, queryParts[0] == "public_key" else { return nil } + let publicKey = String(queryParts[1]) + guard publicKey.count == 64 && Hex.isValid(publicKey) else { return nil } + var server = (useTLS ? "https://" : "http://") + host + if let port = url.port { server += ":\(port)" } + return (room: room, server: server, publicKey: publicKey) + } +} + + +// MARK: - OGMDependencies + +extension OpenGroupManager { + public class OGMDependencies: SMKDependencies { + 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, + generalCache: Atomic? = nil, + storage: Storage? = nil, + sodium: SodiumType? = nil, + box: BoxType? = nil, + genericHash: GenericHashType? = nil, + sign: SignType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + ed25519: Ed25519Type? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) { + _mutableCache = cache + + super.init( + onionApi: onionApi, + generalCache: generalCache, + storage: storage, + sodium: sodium, + box: box, + genericHash: genericHash, + sign: sign, + aeadXChaCha20Poly1305Ietf: aeadXChaCha20Poly1305Ietf, + ed25519: ed25519, + nonceGenerator16: nonceGenerator16, + nonceGenerator24: nonceGenerator24, + standardUserDefaults: standardUserDefaults, + date: date + ) + } + } +} diff --git a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift b/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift deleted file mode 100644 index 66547950d..000000000 --- a/SessionMessagingKit/Open Groups/OpenGroupManagerV2.swift +++ /dev/null @@ -1,186 +0,0 @@ -import PromiseKit - -@objc(SNOpenGroupManagerV2) -public final class OpenGroupManagerV2 : NSObject { - private var pollers: [String:OpenGroupPollerV2] = [:] // One for each server - private var isPolling = false - - // MARK: Initialization - @objc public static let shared = OpenGroupManagerV2() - - private override init() { } - - // 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 - } - } - - @objc public func stopPolling() { - pollers.forEach { (_, openGroupPoller) in openGroupPoller.stop() } - pollers.removeAll() - } - - // MARK: Adding & Removing - - public func hasExistingOpenGroup(room: String, server: String, publicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Bool { - guard let serverUrl: URL = URL(string: server) else { return false } - - let serverHost: String = (serverUrl.host ?? server) - let serverPort: String = (serverUrl.port.map { ":\($0)" } ?? "") - let defaultServerHost: String = OpenGroupAPIV2.defaultServer.substring(from: "http://".count) - var serverOptions: Set = Set([ - server, - "\(serverHost)\(serverPort)", - "http://\(serverHost)\(serverPort)", - "https://\(serverHost)\(serverPort)" - ]) - - if serverHost == OpenGroupAPIV2.legacyDefaultServerDNS { - let defaultServerOptions: Set = Set([ - defaultServerHost, - OpenGroupAPIV2.defaultServer, - "https://\(defaultServerHost)" - ]) - serverOptions = serverOptions.union(defaultServerOptions) - } - else if serverHost == defaultServerHost { - let legacyServerOptions: Set = Set([ - OpenGroupAPIV2.legacyDefaultServerDNS, - "http://\(OpenGroupAPIV2.legacyDefaultServerDNS)", - "https://\(OpenGroupAPIV2.legacyDefaultServerDNS)" - ]) - serverOptions = serverOptions.union(legacyServerOptions) - } - - // First check if there is no poller for the specified server - if serverOptions.first(where: { OpenGroupManagerV2.shared.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).\(room)") - - return (TSGroupThread.fetch(groupId: groupId, transaction: transaction) != nil) - }) - - return hasExistingThread - } - - public func add(room: String, server: String, publicKey: String, using transaction: Any) -> Promise { - // If we are currently polling for this server and already have a TSGroupThread for this room the do nothing - let transaction = transaction as! YapDatabaseReadWriteTransaction - - if hasExistingOpenGroup(room: room, server: server, publicKey: publicKey, using: transaction) { - SNLog("Ignoring join open group attempt (already joined)") - return Promise.value(()) - } - - 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() - - 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 - 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) - } - } - return promise - } - - 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 { - 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) - } - 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) - Storage.shared.removeOpenGroupPublicKey(for: openGroup.server, using: transaction) - thread.removeAllThreadInteractions(with: transaction) - thread.remove(with: transaction) - Storage.shared.removeV2OpenGroup(for: thread.uniqueId!, using: transaction) - } - - // MARK: Convenience - 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: - // https://sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c - // http://sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c - // sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c (does NOT go to HTTPS) - // https://143.198.213.225:443/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c - // 143.198.213.255:80/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c - 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.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: "=") - guard !room.isEmpty && !room.contains("/"), queryParts.count == 2, queryParts[0] == "public_key" else { return nil } - let publicKey = String(queryParts[1]) - guard publicKey.count == 64 && Hex.isValid(publicKey) else { return nil } - var server = (useTLS ? "https://" : "http://") + host - if let port = url.port { server += ":\(port)" } - return (room: room, server: server, publicKey: publicKey) - } -} 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/OpenGroupV2.swift b/SessionMessagingKit/Open Groups/OpenGroupV2.swift deleted file mode 100644 index 504920b76..000000000 --- a/SessionMessagingKit/Open Groups/OpenGroupV2.swift +++ /dev/null @@ -1,41 +0,0 @@ - -@objc(SNOpenGroupV2) -public final class OpenGroupV2 : 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 - /// The ID with which the image can be retrieved from the server. - public let imageID: String? - - public init(server: String, room: String, name: String, publicKey: String, imageID: String?) { - self.server = server.lowercased() - self.room = room - self.id = "\(server).\(room)" - self.name = name - self.publicKey = publicKey - self.imageID = imageID - } - - // 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 - imageID = coder.decodeObject(forKey: "imageID") as! String? - 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") - if let imageID = imageID { coder.encode(imageID, forKey: "imageID") } - } - - override public var description: String { "\(name) (Server: \(server), Room: \(room)" } -} diff --git a/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift b/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift new file mode 100644 index 000000000..50bcf5db9 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/NonceGenerator.swift @@ -0,0 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Sodium + +public protocol NonceGenerator16ByteType { + var NonceBytes: Int { get } + + func nonce() -> Array +} + +public protocol NonceGenerator24ByteType { + var NonceBytes: Int { get } + + func nonce() -> Array +} + +extension OpenGroupAPI { + public class NonceGenerator16Byte: NonceGenerator, NonceGenerator16ByteType { + public var NonceBytes: Int { 16 } + } + + public class NonceGenerator24Byte: NonceGenerator, NonceGenerator24ByteType { + public var NonceBytes: Int { 24 } + } +} 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/Personalization.swift b/SessionMessagingKit/Open Groups/Types/Personalization.swift new file mode 100644 index 000000000..e1285c212 --- /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 OpenGroupAPI { + 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/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift new file mode 100644 index 000000000..052b2fe80 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -0,0 +1,123 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension OpenGroupAPI { + public enum Endpoint: EndpointType { + // 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, id: Int64) + case roomMessagesRecent(String) + case roomMessagesBefore(String, id: Int64) + case roomMessagesSince(String, seqNo: Int64) + case roomDeleteMessages(String, sessionId: String) + + // Pinning + + case roomPinMessage(String, id: Int64) + case roomUnpinMessage(String, id: Int64) + case roomUnpinAll(String) + + // Files + + case roomFile(String) + case roomFileIndividual(String, String) + + // Inbox/Outbox (Message Requests) + + case inbox + case inboxSince(id: Int64) + case inboxFor(sessionId: String) + + case outbox + case outboxSince(id: Int64) + + // Users + + case userBan(String) + case userUnban(String) + case userModerator(String) + + 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)" + + case .roomDeleteMessages(let roomToken, let sessionId): + return "room/\(roomToken)/all/\(sessionId)" + + // 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 .roomFileIndividual(let roomToken, let fileId): return "room/\(roomToken)/file/\(fileId)" + + // 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 + + case .userBan(let sessionId): return "user/\(sessionId)/ban" + case .userUnban(let sessionId): return "user/\(sessionId)/unban" + case .userModerator(let sessionId): return "user/\(sessionId)/moderator" + } + } + } +} diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift new file mode 100644 index 000000000..3e3842f4d --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -0,0 +1,107 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import Curve25519Kit + +public protocol SodiumType { + func getBox() -> BoxType + func getGenericHash() -> GenericHashType + func getSign() -> SignType + func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType + + 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, genericHash: GenericHashType) -> Bool +} + +public protocol AeadXChaCha20Poly1305IetfType { + 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 { + func sign(data: Bytes, keyPair: Box.KeyPair) throws -> Bytes? + func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool +} + +public protocol BoxType { + func seal(message: Bytes, recipientPublicKey: Bytes) -> Bytes? + func open(anonymousCipherText: Bytes, recipientPublicKey: Bytes, recipientSecretKey: Bytes) -> Bytes? +} + +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? +} + +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) + } + + func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes) -> Bytes? { + return decrypt(authenticatedCipherText: authenticatedCipherText, secretKey: secretKey, nonce: nonce, additionalData: nil) + } +} + +// 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 } + + public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair) -> Box.KeyPair? { + return blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: edKeyPair, genericHash: getGenericHash()) + } +} + +extension Box: BoxType {} +extension GenericHash: GenericHashType {} +extension Sign: SignType {} +extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} + +struct Ed25519Wrapper: Ed25519Type { + 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 { + return try Ed25519.verifySignature(signature, publicKey: publicKey, data: data) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift b/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift deleted file mode 100644 index ac20fe716..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/OWSThumbnailService.swift +++ /dev/null @@ -1,168 +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: TSAttachmentStream - let thumbnailDimensionPoints: 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 { - - // 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: TSAttachmentStream) -> 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) - - 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.path(forThumbnailDimensionPoints: thumbnailRequest.thumbnailDimensionPoints) - 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.thumbnailDimensionPoints) - 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/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift index 0087c3663..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; @@ -331,9 +290,6 @@ public class SignalAttachment: NSObject { } } } - if isOversizeText { - return OWSMimeTypeOversizeTextMessage - } if dataUTI == kUnknownTestAttachmentUTI { return OWSMimeTypeUnknownForTests } @@ -345,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() @@ -367,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 @@ -375,9 +329,6 @@ public class SignalAttachment: NSObject { return fileExtension.filterFilename() } } - if isOversizeText { - return kOversizeTextAttachmentFileExtension - } if dataUTI == kUnknownTestAttachmentUTI { return "unknown" } @@ -455,18 +406,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) ) } @@ -529,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 @@ -596,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) @@ -684,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) @@ -1056,20 +997,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. @@ -1085,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 @@ -1098,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) } @@ -1107,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) @@ -1120,7 +1044,6 @@ public class SignalAttachment: NSObject { } } - @objc public class func empty() -> SignalAttachment { return SignalAttachment.attachment(dataSource: DataSourceValue.emptyDataSource(), dataUTI: kUTTypeContent as String, @@ -1165,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 a663f8eaf..000000000 --- a/SessionMessagingKit/Sending & Receiving/Attachments/TSAttachmentStream.h +++ /dev/null @@ -1,104 +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; - -- (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/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/Data Extraction/DataExtractionNotificationInfoMessage.swift b/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift deleted file mode 100644 index 18ceef7f5..000000000 --- a/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift +++ /dev/null @@ -1,29 +0,0 @@ - -@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 sessionID = thread.contactSessionID() - let displayName = Storage.shared.getContact(with: sessionID)?.displayName(for: .regular) ?? sessionID - 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 new file mode 100644 index 000000000..5f708431e --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -0,0 +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/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift new file mode 100644 index 000000000..2d94b8946 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -0,0 +1,53 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum MessageReceiverError: LocalizedError { + case duplicateMessage + case duplicateControlMessage + 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, .duplicateControlMessage, .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 .duplicateControlMessage: return "Duplicate control 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/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 82c3071ae..000000000 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingConfigurationUpdateInfoMessage.m +++ /dev/null @@ -1,91 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSDisappearingConfigurationUpdateInfoMessage.h" -#import "OWSDisappearingMessagesConfiguration.h" -#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 dfbee9b3a..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 9e93fbbff..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 31e797171..000000000 --- a/SessionMessagingKit/Sending & Receiving/Expiration/OWSDisappearingMessagesJob.m +++ /dev/null @@ -1,372 +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) { - SNContactContext context = [SNContact contextForThread:thread]; - remoteContactName = [[LKStorage.shared getContactWithSessionID:remoteRecipientId] displayNameFor:context] ?: remoteRecipientId; - } - - // 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.enabled = NO; - } else { - disappearingMessagesConfiguration.enabled = 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/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 cb0cd1919..000000000 --- a/SessionMessagingKit/Sending & Receiving/Link Previews/OWSLinkPreview.swift +++ /dev/null @@ -1,723 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import AFNetworking -import Foundation -import PromiseKit - -@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 SSKPreferences.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 SSKPreferences.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 { - 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/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 9463002b6..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 - 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 !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/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift new file mode 100644 index 000000000..de74c53f7 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -0,0 +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 new file mode 100644 index 000000000..e2312383d --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -0,0 +1,510 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import SessionUtilitiesKit + +extension MessageReceiver { + public static func handleClosedGroupControlMessage(_ db: Database, _ message: ClosedGroupControlMessage) throws { + switch message.kind { + 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 + + 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 + } + 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 + ) + } + + internal 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) + var hasApprovedAdmin: Bool = false + + for adminId in admins { + if let contact: Contact = try? Contact.fetchOne(db, id: adminId), contact.isApproved { + hasApprovedAdmin = true + break + } + } + + guard hasApprovedAdmin else { return } + + // Create the group + 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: (TimeInterval(messageSentTimestamp) / 1000) + ).saved(db) + + // 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) + } + + // 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 + // • 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(messageSentTimestamp) + ).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) + + // Store the key pair + try ClosedGroupKeyPair( + threadId: groupPublicKey, + publicKey: Data(encryptionKeyPair.publicKey), + 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(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 + /// sent by the group admin. + 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.") + } + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { + return SNLog("Ignoring closed group encryption key pair for nonexistent group.") + } + 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 + let userPublicKey: String = SessionId(.standard, publicKey: userKeyPair.publicKey).hexString + + 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 { + return SNLog("Couldn't decrypt closed group encryption key pair.") + } + + // Parse it + let proto: SNProtoKeyPair + do { + proto = try SNProtoKeyPair.parseData(plaintext) + } + catch { + return SNLog("Couldn't parse closed group encryption key pair.") + } + + do { + try ClosedGroupKeyPair( + threadId: groupPublicKey, + publicKey: proto.publicKey.removingIdPrefixIfNeeded(), + secretKey: proto.privateKey, + receivedTimestamp: Date().timeIntervalSince1970 + ).insert(db) + } + catch { + 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.") + } + + private static func handleClosedGroupNameChanged(_ db: Database, message: ClosedGroupControlMessage) throws { + guard case let .nameChange(name) = message.kind else { return } + + try performIfValid(db, message: message) { id, sender, thread, closedGroup in + _ = try ClosedGroup + .filter(id: id) + .updateAll(db, ClosedGroup.Columns.name.set(to: name)) + + // Notify the user if needed + guard name != closedGroup.name else { return } + + _ = try Interaction( + serverHash: message.serverHash, + threadId: thread.id, + authorId: sender, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .nameChange(name: name) + .infoMessage(db, sender: sender), + timestampMs: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + ).inserted(db) + } + } + + private static func handleClosedGroupMembersAdded(_ db: Database, message: ClosedGroupControlMessage) throws { + guard case let .membersAdded(membersAsData) = message.kind else { return } + + 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: [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) } + .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 + // + // 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. + // • 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 userPublicKey: String = getUserHexEncodedPublicKey(db) + + if groupAdmins.contains(where: { $0.profileId == userPublicKey }) { + addedMembers.forEach { memberId in + MessageSender.sendLatestEncryptionKeyPair(db, to: memberId, for: id) + } + } + + // Remove any 'zombie' versions of the added members (in case they were re-added) + _ = try GroupMember + .filter(GroupMember.Columns.groupId == id) + .filter(GroupMember.Columns.role == GroupMember.Role.zombie) + .filter(addedMembers.contains(GroupMember.Columns.profileId)) + .deleteAll(db) + + // Notify the user if needed + guard members != Set(groupMembers.map { $0.profileId }) else { return } + + _ = try Interaction( + serverHash: message.serverHash, + 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: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + ).inserted(db) + } + } + + /// Removes the given members from the group IF + /// • it wasn't the admin that was removed (that should happen through a `MEMBER_LEFT` message). + /// • 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(_ db: Database, message: ClosedGroupControlMessage) throws { + guard case let .membersRemoved(membersAsData) = message.kind else { return } + + 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(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 groupAdmins.contains(where: { $0.profileId == sender }) else { + 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 + // • Notify the PN server + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let wasCurrentUserRemoved: Bool = !members.contains(userPublicKey) + + if wasCurrentUserRemoved { + ClosedGroupPoller.shared.stopPolling(for: id) + + _ = try closedGroup + .keyPairs + .deleteAll(db) + + let _ = PushNotificationAPI.performOperation( + .unsubscribe, + for: id, + publicKey: userPublicKey + ) + } + + // Notify the user if needed + guard members != Set(groupMembers.map { $0.profileId }) else { return } + + _ = try Interaction( + serverHash: message.serverHash, + 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: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + ).inserted(db) + } + } + + /// If a regular member left: + /// • 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(_ db: Database, message: ClosedGroupControlMessage) throws { + guard case .memberLeft = message.kind else { return } + + try performIfValid(db, message: message) { id, sender, thread, closedGroup in + guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { + return + } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + 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 }) + + // 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 + .keyPairs + .deleteAll(db) + + let _ = PushNotificationAPI.performOperation( + .unsubscribe, + for: id, + publicKey: userPublicKey + ) + } + + // Re-add the removed member as a zombie (unless the admin left which disbands the + // group) + if !didAdminLeave { + try GroupMember( + groupId: id, + profileId: sender, + role: .zombie + ).insert(db) + } + + // Notify the user if needed + guard updatedMemberIds != Set(members.map { $0.profileId }) else { return } + + _ = try Interaction( + serverHash: message.serverHash, + threadId: thread.id, + authorId: sender, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .memberLeft + .infoMessage(db, sender: sender), + timestampMs: ( + message.sentTimestamp.map { Int64($0) } ?? + Int64(floor(Date().timeIntervalSince1970 * 1000)) + ) + ).inserted(db) + } + } + + private static func handleClosedGroupEncryptionKeyPairRequest(_ db: Database, message: ClosedGroupControlMessage) { + /* + guard case .encryptionKeyPairRequest = message.kind else { return } + let transaction = transaction as! YapDatabaseReadWriteTransaction + guard let groupPublicKey = message.groupPublicKey else { return } + performIfValid(for: message, using: transaction) { groupID, _, group in + let publicKey = message.sender! + // Guard against self-sends + guard publicKey != getUserHexEncodedPublicKey() else { + return SNLog("Ignoring invalid closed group update.") + } + MessageSender.sendLatestEncryptionKeyPair(to: publicKey, for: groupPublicKey, using: transaction) + } + */ + } + + // MARK: - Convenience + + 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.") + } + guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { return } + + // Check that the message isn't from before the group 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 members.contains(where: { $0.profileId == sender }) else { + return SNLog("Ignoring closed group update from non-member.") + } + + try update(groupPublicKey, sender, thread, closedGroup) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift new file mode 100644 index 000000000..1259720ae --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -0,0 +1,177 @@ +// 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 (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, + name: message.displayName, + profilePictureUrl: message.profilePictureUrl, + profileKey: OWSAES256Key(data: message.profileKey), + sentTimestamp: messageSentTimestamp + ) + try Contact(id: userPublicKey) + .with( + isApproved: true, + didApproveMe: true + ) + .save(db) + + 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 } + + // 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) + let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) + + 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) + } + + /// 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` + 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) + } + + // 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 new file mode 100644 index 000000000..4b1766a0a --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -0,0 +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 new file mode 100644 index 000000000..bb5f3bfaa --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift @@ -0,0 +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 new file mode 100644 index 000000000..782d288a7 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -0,0 +1,157 @@ +// 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: SMKDependencies + ) throws { + let userPublicKey = getUserHexEncodedPublicKey(db, dependencies: dependencies) + var blindedContactIds: [String] = [] + + // 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) + + // 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 + // 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: blindedContactIds.isEmpty // Sync here if there were no blinded contacts + ) + + // 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, + 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) + else { return } + + // 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) + .saved(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) + let contact: Contact = Contact.fetchOrCreate(db, id: senderSessionId) + + guard !contact.didApproveMe else { return } + + _ = try? contact + .with(didApproveMe: true) + .saved(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 new file mode 100644 index 000000000..913610e83 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ReadReceipts.swift @@ -0,0 +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 new file mode 100644 index 000000000..46807cf60 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift @@ -0,0 +1,36 @@ +// 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: + let needsToStartTypingIndicator: Bool = TypingIndicators.didStartTypingNeedsToStart( + threadId: thread.id, + threadVariant: thread.variant, + threadIsMessageRequest: thread.isMessageRequest(db), + direction: .incoming, + 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) + + 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 new file mode 100644 index 000000000..03a9e1bf3 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -0,0 +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 new file mode 100644 index 000000000..a18e0560b --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -0,0 +1,345 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +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, dependencies: dependencies) + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) + 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 + 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: 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 ? + 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: (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 { + 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) + // 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) + } + + // 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 } + + switch thread.variant { + case .contact: + if let syncTarget: String = syncTarget { + try RecipientState( + interactionId: interactionId, + 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 + // 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 new file mode 100644 index 000000000..ff813332e --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -0,0 +1,628 @@ +// 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 distributingKeyPairs: Atomic<[String: [ClosedGroupKeyPair]]> = Atomic([:]) + + public static func createClosedGroup(_ db: Database, name: String, members: Set) throws -> Promise { + let userPublicKey: String = getUserHexEncodedPublicKey(db) + var members: Set = members + + // Generate the group's public key + 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() + + // 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 formationTimestamp: TimeInterval = Date().timeIntervalSince1970 + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup) + try ClosedGroup( + threadId: groupPublicKey, + name: name, + formationTimestamp: formationTimestamp + ).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 { memberId in + try GroupMember( + groupId: groupPublicKey, + profileId: memberId, + role: .standard + ).insert(db) + } + + try members.forEach { memberId in + let contactThread: SessionThread = try SessionThread + .fetchOrCreate(db, id: memberId, variant: .contact) + + // 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: Box.KeyPair( + publicKey: encryptionKeyPair.publicKey.bytes, + secretKey: encryptionKeyPair.privateKey.bytes + ), + members: membersAsData, + admins: adminsAsData, + expirationTimer: 0 + ), + // Note: We set this here to ensure the value matches the 'ClosedGroup' + // object we created + sentTimestampMs: UInt64(floor(formationTimestamp * 1000)) + ), + interactionId: nil, + in: contactThread + ) + ) + } + + // Store the key pair + try ClosedGroupKeyPair( + threadId: groupPublicKey, + publicKey: encryptionKeyPair.publicKey, + secretKey: encryptionKeyPair.privateKey, + receivedTimestamp: Date().timeIntervalSince1970 + ).insert(db) + + // Notify the PN server + promises.append( + PushNotificationAPI.performOperation( + .subscribe, + for: groupPublicKey, + publicKey: userPublicKey + ) + ) + + // Notify the user + // + // 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 when(fulfilled: promises).map2 { thread } + } + + /// 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. + 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 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: newKeyPair.publicKey, + privateKey: newKeyPair.secretKey + ).build() + let plaintext = try proto.serializedData() + + distributingKeyPairs.mutate { + $0[closedGroup.id] = ($0[closedGroup.id] ?? []) + .appending(newKeyPair) + } + + 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 + Storage.shared.write { db in + try newKeyPair.insert(db) + + distributingKeyPairs.mutate { + if let index = ($0[closedGroup.id] ?? []).firstIndex(of: newKeyPair) { + $0[closedGroup.id] = ($0[closedGroup.id] ?? []) + .removing(index: index) + } + } + } + } + .map { _ in } + } + catch { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) + } + } + + public static func update( + _ db: Database, + groupPublicKey: String, + with members: Set, + name: String + ) throws -> Promise { + // Get the group, check preconditions & prepare + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { + SNLog("Can't update nonexistent closed group.") + return Promise(error: MessageSenderError.noThread) + } + 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 != closedGroup.name { + // Update the group + _ = try ClosedGroup + .filter(id: closedGroup.id) + .updateAll(db, ClosedGroup.Columns.name.set(to: name)) + + // 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 StorageError.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 + 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 = 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) + } + } + + return Promise.value(()) + } + + + /// 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). + 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 StorageError.objectNotFound + } + guard let encryptionKeyPair: ClosedGroupKeyPair = try closedGroup.fetchLatestKeyPair(db) else { + throw StorageError.objectNotFound + } + + 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 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 StorageError.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) + + 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. + 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.") + throw MessageSenderError.invalidClosedGroupUpdate + } + guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else { + SNLog("Only an admin can remove members from a group.") + throw MessageSenderError.invalidClosedGroupUpdate + } + + 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 GroupMember + .filter(GroupMember.Columns.groupId == thread.id) + .filter(removedMembers.contains(GroupMember.Columns.profileId)) + .filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role)) + .deleteAll(db) + + let interactionId: Int64? + + // Notify the user if needed (not if only zombie members were removed) + 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 StorageError.objectNotSaved } + + interactionId = newInteractionId + } + 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 + } + + /// 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. + /// + /// The returned promise is fulfilled when the `MEMBER_LEFT` message has been sent to the group. + 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: MessageSenderError.noThread) + } + guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else { + return Promise(error: MessageSenderError.invalidClosedGroupUpdate) + } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // Notify the user + 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 StorageError.objectNotSaved + } + + // Send the update to the group + let promise = try MessageSender + .sendNonDurably( + db, + message: ClosedGroupControlMessage( + kind: .memberLeft + ), + interactionId: interactionId, + in: thread + ) + .done { + // Remove the group from the database and unsubscribe from PNs + ClosedGroupPoller.shared.stopPolling(for: groupPublicKey) + + Storage.shared.write { db in + try closedGroup + .keyPairs + .deleteAll(db) + + let _ = PushNotificationAPI.performOperation( + .unsubscribe, + for: groupPublicKey, + publicKey: userPublicKey + ) + } + } + .map { _ in } + + // 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 + } + + /* + public static func requestEncryptionKeyPair(for groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) throws { + #if DEBUG + preconditionFailure("Shouldn't currently be in use.") + #endif + // 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 request encryption key pair for nonexistent closed group.") + throw Error.noThread + } + let group = thread.groupModel + guard group.groupMemberIds.contains(getUserHexEncodedPublicKey()) else { return } + // Send the request to the group + let closedGroupControlMessage = ClosedGroupControlMessage(kind: .encryptionKeyPairRequest) + MessageSender.send(closedGroupControlMessage, in: thread, using: transaction) + } + */ + + 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.") + } + 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 + var maybeKeyPair: ClosedGroupKeyPair? = distributingKeyPairs.wrappedValue[groupPublicKey]?.last + + if maybeKeyPair == nil { + maybeKeyPair = try? closedGroup.fetchLatestKeyPair(db) + } + + guard let keyPair: ClosedGroupKeyPair = maybeKeyPair else { return } + + // Send it + do { + let proto = try SNProtoKeyPair.builder( + publicKey: keyPair.publicKey, + privateKey: keyPair.secretKey + ).build() + let plaintext = try proto.serializedData() + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: publicKey, variant: .contact) + 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/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index 20846e9d5..e1ac4b392 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -1,30 +1,113 @@ -import CryptoSwift -import SessionUtilitiesKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import Sodium +import CryptoSwift +import Curve25519Kit +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()) - let sodium = Sodium() - let signatureSize = sodium.sign.Bytes - let ed25519PublicKeySize = sodium.sign.PublicKeyBytes + 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 + 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 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]) let plaintext = Bytes(plaintextWithMetadata[0.. (plaintext: Data, senderX25519PublicKey: String) { + /// Ensure the data is at least long enough to have the required components + 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 + let kA: Bytes = (isOutgoing ? blindedKeyPair.publicKey : otherKeyBytes) + guard let dec_key: Bytes = dependencies.sodium.sharedBlindedEncryptionKey( + secretKey: userEd25519KeyPair.secretKey, + otherBlindedPublicKey: otherKeyBytes, + fromBlindedPublicKey: kA, + toBlindedPublicKey: (isOutgoing ? otherKeyBytes : blindedKeyPair.publicKey), + genericHash: dependencies.genericHash + ) else { + throw MessageReceiverError.decryptionFailed + } + + /// 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 MessageReceiverError.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, genericHash: dependencies.genericHash) else { + throw MessageReceiverError.invalidSignature + } + guard let sharedSecret: Bytes = dependencies.sodium.combineKeys(lhsKeyBytes: blindingFactor, rhsKeyBytes: sender_edpk) else { + throw MessageReceiverError.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 MessageReceiverError.decryptionFailed + } + + return (Data(plaintext), SessionId(.standard, publicKey: senderSessionIdBytes).hexString) } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift deleted file mode 100644 index e3d69d3ea..000000000 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ /dev/null @@ -1,891 +0,0 @@ -import SignalCoreKit -import SessionSnodeKit -import WebRTC - -extension MessageReceiver { - - public static func handle(_ 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 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 UnsendRequest: handleUnsendRequest(message, using: transaction) - case let message as CallMessage: handleCallMessage(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) - default: fatalError() - } - - var isMainAppAndActive = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppAndActive = sharedUserDefaults.bool(forKey: "isMainAppActive") - } - 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 } - ThreadUpdateBatcher.shared.touch(threadID) - } - - - - // 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!) - } - - - - // 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!) - } - } - - public static func showTypingIndicatorIfNeeded(for senderPublicKey: String) { - var threadOrNil: TSContactThread? - Storage.read { transaction in - threadOrNil = TSContactThread.fetch(for: senderPublicKey, using: 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() - } - } - } - - public static func hideTypingIndicatorIfNeeded(for senderPublicKey: String) { - var threadOrNil: TSContactThread? - Storage.read { transaction in - threadOrNil = TSContactThread.fetch(for: senderPublicKey, using: 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.fetch(for: senderPublicKey, using: 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 - - private static func handleDataExtractionNotification(_ message: DataExtractionNotification, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - guard message.groupPublicKey == nil, - let thread = TSContactThread.fetch(for: message.sender!, using: 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) - } - - - - // 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) - } - } - - 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.fetch(for: syncTarget ?? senderPublicKey, using: transaction) - } - guard let thread = threadOrNil else { return } - let configuration = OWSDisappearingMessagesConfiguration(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 - } - 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.fetch(for: syncTarget ?? senderPublicKey, using: transaction) - } - guard let thread = threadOrNil else { return } - let configuration = OWSDisappearingMessagesConfiguration(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 - } - 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(_ message: ConfigurationMessage, using transaction: Any) { - let userPublicKey = getUserHexEncodedPublicKey() - guard message.sender == userPublicKey else { return } - SNLog("Configuration message received.") - let storage = SNMessagingKitConfiguration.shared.storage - let transaction = transaction as! YapDatabaseReadWriteTransaction - 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) - - // 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) - - if isInitialSync || messageSentTimestamp > lastConfigTimestamp { - if isInitialSync { - UserDefaults.standard[.hasSyncedInitialConfiguration] = true - NotificationCenter.default.post(name: .initialConfigurationMessageReceived, object: nil) - } - - UserDefaults.standard[.lastConfigurationSync] = Date(timeIntervalSince1970: messageSentTimestamp) - - // 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 - - // 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. - 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) - - // 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 != contactWasBlocked, - let thread: TSContactThread = TSContactThread.fetch(for: sessionID, using: transaction), - thread.isMessageRequest(using: transaction) - { - thread.removeAllThreadInteractions(with: transaction) - thread.remove(with: transaction) - } - } - } - - // 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 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, - members: [String](closedGroup.members), admins: [String](closedGroup.admins), expirationTimer: closedGroup.expirationTimer, - messageSentTimestamp: message.sentTimestamp!, using: transaction) - } - } - - // 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() - } - } - } - } - - - - // MARK: - Unsend Requests - - public static func handleUnsendRequest(_ message: UnsendRequest, using transaction: Any) { - let userPublicKey = getUserHexEncodedPublicKey() - 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) - } - } - } - } - - - - // MARK: - Call Messages - - public static func handleCallMessage(_ message: CallMessage, using transaction: Any) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - switch message.kind! { - case .preOffer: - SNLog("[Calls] Received pre-offer message.") - // It is enough just ignoring the pre offers, other call messages - // for this call would be dropped because of no Session call instance - guard let sender = message.sender, let contact = Storage.shared.getContact(with: sender), contact.isApproved else { return } - handleNewCallOfferMessageIfNeeded?(message, transaction) - case .offer: - SNLog("[Calls] Received offer message.") - handleOfferCallMessage?(message) - case .answer: - SNLog("[Calls] Received answer message.") - guard let currentWebRTCSession = WebRTCSession.current, currentWebRTCSession.uuid == message.uuid! else { return } - handleAnswerCallMessage?(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.. String { - let storage = SNMessagingKitConfiguration.shared.storage - let transaction = transaction as! YapDatabaseReadWriteTransaction - var isMainAppAndActive = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppAndActive = sharedUserDefaults.bool(forKey: "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 - // 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) - } - // 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 } - // 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 - let isContactTrusted = Storage.shared.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) - 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 - - // 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) - } - } - - // Keep track of server hash - if let serverHash = message.serverHash { tsMessage.serverHash = serverHash } - tsMessage.save(with: transaction) - } - 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) - } - - // 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 !isGroup, let senderSessionId: String = message.sender { - updateContactApprovalStatusIfNeeded( - senderSessionId: senderSessionId, - threadId: message.threadID, - forceConfigSync: false, - using: transaction - ) - } - - // 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 } - // 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 - } - - - - // 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 - let contact = Storage.shared.getContact(with: publicKey) ?? Contact(sessionID: publicKey) // New API - // Name - if let name = name, name != contact.name { - let shouldUpdate: Bool - if isCurrentUser { - shouldUpdate = given(userDefaults[.lastDisplayNameUpdate]) { sentTimestamp > UInt64($0.timeIntervalSince1970 * 1000) } ?? true - } else { - shouldUpdate = true - } - if shouldUpdate { - if isCurrentUser { - userDefaults[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: TimeInterval(sentTimestamp / 1000)) - } - contact.name = name - } - } - // Profile picture & profile key - if let profileKey = profileKey, let profilePictureURL = profilePictureURL, - profileKey.keyData.count == kAES256_KeyByteLength, profileKey != contact.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 - } - } - // Persist changes - Storage.shared.setContact(contact, using: transaction) - // Download the profile picture if needed - transaction.addCompletionQueue(DispatchQueue.main) { - SSKEnvironment.shared.profileManager.downloadAvatar(forUserProfile: contact) - } - } - - - - // MARK: - Closed Groups - public static func handleClosedGroupControlMessage(_ message: ClosedGroupControlMessage, using transaction: Any) { - switch message.kind! { - case .new: handleNewClosedGroup(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 - } - } - - private static func handleNewClosedGroup(_ 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, - 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) { - let transaction = transaction as! YapDatabaseReadWriteTransaction - - // 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) - var hasApprovedAdmin: Bool = false - - for adminId in admins { - if let contact: Contact = Storage.shared.getContact(with: adminId), contact.isApproved { - hasApprovedAdmin = true - break - } - } - - 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 - - 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) - } - - let isExpirationTimerEnabled = (expirationTimer > 0) - let expirationTimerDuration = (isExpirationTimerEnabled ? expirationTimer : 24 * 60 * 60) - let configuration = OWSDisappearingMessagesConfiguration( - threadId: thread.uniqueId!, - enabled: isExpirationTimerEnabled, - durationSeconds: expirationTimerDuration - ) - configuration.save(with: transaction) - - // 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) - // 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 = SNMessagingKitConfiguration.shared.storage.getUserKeyPair() 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 { - return SNLog("Ignoring closed group encryption key pair for nonexistent group.") - } - guard thread.groupModel.groupAdminIds.contains(message.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 plaintext: Data - do { - 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 { - 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.") - } - // Store it if needed - let closedGroupEncryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: groupPublicKey) - guard !closedGroupEncryptionKeyPairs.contains(keyPair) else { - return SNLog("Ignoring duplicate closed group encryption key pair.") - } - Storage.shared.addClosedGroupEncryptionKeyPair(keyPair, for: groupPublicKey, using: transaction) - SNLog("Received a new closed group encryption key pair.") - } - - private static func handleClosedGroupNameChanged(_ message: ClosedGroupControlMessage, using transaction: Any) { - 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) - // 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) - } - } - - private static func handleClosedGroupMembersAdded(_ message: ClosedGroupControlMessage, using transaction: Any) { - 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 - // 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 - // - // 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. - // • 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) - } - } - // 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) - } - // 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) - } - } - - /// Removes the given members from the group IF - /// • it wasn't the admin that was removed (that should happen through a `MEMBER_LEFT` message). - /// • 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) { - 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 - // Check that the admin wasn't removed - let removedMembers = membersAsData.map { $0.toHexString() } - let members = Set(group.groupMemberIds).subtracting(removedMembers) - guard members.contains(group.groupAdminIds.first!) 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 { - 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) - } - 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) - // 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) - } - } - - /// If a regular member left: - /// • 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) { - guard case .memberLeft = 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 - 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 - 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 contact = Storage.shared.getContact(with: message.sender!) - let updateInfo: String - if let displayName = contact?.displayName(for: Contact.Context.regular) { - updateInfo = String(format: NSLocalizedString("GROUP_MEMBER_LEFT", comment: ""), displayName) - } else { - updateInfo = NSLocalizedString("GROUP_UPDATED", comment: "") - } - let infoMessage = TSInfoMessage(timestamp: message.sentTimestamp!, in: thread, messageType: .groupUpdated, customMessage: updateInfo) - infoMessage.save(with: transaction) - } - } - - private static func handleClosedGroupEncryptionKeyPairRequest(_ message: ClosedGroupControlMessage, using transaction: Any) { - /* - guard case .encryptionKeyPairRequest = message.kind else { return } - let transaction = transaction as! YapDatabaseReadWriteTransaction - guard let groupPublicKey = message.groupPublicKey else { return } - performIfValid(for: message, using: transaction) { groupID, _, group in - let publicKey = message.sender! - // Guard against self-sends - guard publicKey != getUserHexEncodedPublicKey() else { - return SNLog("Ignoring invalid closed group update.") - } - MessageSender.sendLatestEncryptionKeyPair(to: publicKey, for: groupPublicKey, using: transaction) - } - */ - } - - 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 { - return SNLog("Ignoring closed group update for nonexistent group.") - } - let group = thread.groupModel - // 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.") - } - } - // Check that the sender is a member of the group - guard Set(group.groupMemberIds).contains(message.sender!) else { - return SNLog("Ignoring closed group update from non-member.") - } - // Perform the update - update(groupID, thread, group) - } - - // MARK: - Message Requests - - private static func updateContactApprovalStatusIfNeeded( - senderSessionId: String, - threadId: String?, - forceConfigSync: Bool, - using transaction: Any - ) { - guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } - - let userPublicKey: String = getUserHexEncodedPublicKey() - - // 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 } - - contact.isApproved = true - Storage.shared.setContact(contact, using: transaction) - } - 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) - } - - // Force a config sync to ensure all devices know the contact approval state if desired - guard forceConfigSync else { return } - - transaction.addCompletionQueue(Threading.jobQueue) { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() - } - } - - public static func handleMessageRequestResponse(_ message: MessageRequestResponse, using transaction: Any) { - let userPublicKey = getUserHexEncodedPublicKey() - - // 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.fetch(for: senderId, using: transaction) { - let infoMessage = TSInfoMessage( - timestamp: (message.sentTimestamp ?? NSDate.ows_millisecondTimeStamp()), - in: thread, - messageType: .messageRequestAccepted - ) - infoMessage.save(with: transaction) - } - - updateContactApprovalStatusIfNeeded( - senderSessionId: senderId, - threadId: nil, - forceConfigSync: true, - using: transaction - ) - } -} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 674ad25a5..4524b75d2 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -1,190 +1,361 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import SignalCoreKit import SessionUtilitiesKit public enum MessageReceiver { - private static var lastEncryptionKeyPairRequest: [String:Date] = [:] - 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 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(_ data: Data, openGroupMessageServerID: UInt64?, 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 + private static var lastEncryptionKeyPairRequest: [String: Date] = [:] + + public static func parse( + _ db: Database, + envelope: SNProtoEnvelope, + serverExpirationTimestamp: TimeInterval?, + openGroupId: String?, + openGroupMessageServerId: Int64?, + openGroupServerPublicKey: String?, + isOutgoing: Bool? = nil, + otherBlindedPublicKey: String? = nil, + dependencies: SMKDependencies = SMKDependencies() + ) throws -> (Message, SNProtoContent, String) { + let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let isOpenGroupMessage: Bool = (openGroupId != nil) + // 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 = 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: 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 MessageReceiverError.noData + } + 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( + data: ciphertext, + isOutgoing: (isOutgoing == true), + otherBlindedPublicKey: otherBlindedPublicKey, + with: openGroupServerPublicKey, + userEd25519KeyPair: userEd25519KeyPair, + using: dependencies + ) + } + + 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: keyPair.publicKey.bytes, + secretKey: keyPair.secretKey.bytes + ) + ) + } + catch { + return try decrypt(keyPairs: Array(keyPairs.suffix(from: 1)), lastError: error) } } - } - 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 + + groupPublicKey = hexEncodedGroupPublicKey + (plaintext, sender) = try decrypt(keyPairs: encryptionKeyPairs) + + default: throw MessageReceiverError.unknownEnvelopeType } } // Don't process the envelope any further if the sender is blocked - guard Storage.shared.getContact(with: sender, using: transaction)?.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) { 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 callMessage = CallMessage.fromProto(proto) { return callMessage } - return nil + guard let message: Message = Message.createMessageFrom(proto, sender: sender) else { + 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.map { UInt64($0) } + + // 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 + } }() - if let message = message { - // Ignore self sends if needed - if !message.isSelfSendValid { - guard sender != userPublicKey else { throw Error.selfSend } - } - // Guard against control messages in open groups - if isOpenGroupMessage { - guard message is VisibleMessage else { throw Error.invalidMessage } - } - // Finish parsing - message.sender = sender - message.recipient = userPublicKey - message.sentTimestamp = envelope.timestamp - message.receivedTimestamp = NSDate.millisecondTimestamp() - if isOpenGroupMessage { - message.openGroupServerTimestamp = envelope.serverTimestamp - } - message.groupPublicKey = groupPublicKey - message.openGroupServerMessageID = openGroupMessageServerID - // Validate - var isValid = message.isValid - if message is VisibleMessage && !isValid && proto.dataMessage?.attachments.isEmpty == false { - isValid = true - } - guard isValid else { - throw Error.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 message.shouldBeRetryable { - // Allow duplicates for new closed group & encryption key pair update: - // • 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 + + return (message, proto, threadId) + } + + // MARK: - Handling + + public static func handle( + _ db: Database, + message: Message, + associatedWithProto proto: SNProtoContent, + openGroupId: String?, + isBackgroundPoll: Bool, + dependencies: SMKDependencies = SMKDependencies() + ) throws { + switch message { + case let message as ReadReceipt: + try MessageReceiver.handleReadReceipt(db, message: message) - // 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 - } else { - guard !Set(storage.getReceivedMessageTimestamps(using: transaction)).contains(envelope.timestamp) || isRetry else { throw Error.duplicateMessage } - storage.addReceivedMessageTimestamp(envelope.timestamp, using: transaction) + 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() + } + + // 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 { + 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, dependencies: dependencies)) + let profile: Profile = Profile.fetchOrCreate(id: publicKey) + var updatedProfile: Profile = profile + + // 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) + } + + updatedProfile = updatedProfile.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) + } + + updatedProfile = updatedProfile.with( + profilePictureUrl: .update(profilePictureUrl), + profileEncryptionKey: .update(profileKey) + ) + } + } + + // Persist any changes + if updatedProfile != profile { + try updatedProfile.save(db) + } + + // Download the profile picture if needed + if updatedProfile.profilePictureUrl != profile.profilePictureUrl || updatedProfile.profileEncryptionKey != profile.profileEncryptionKey { + db.afterNextTransactionCommit { _ in + ProfileManager.downloadAvatar(for: updatedProfile) } - // Return - return (message, proto) - } else { - throw Error.unknownMessage } } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift deleted file mode 100644 index 8b75e2f44..000000000 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+ClosedGroups.swift +++ /dev/null @@ -1,337 +0,0 @@ -import PromiseKit - -extension MessageSender { - public static var distributingClosedGroupEncryptionKeyPairs: [String:[ECKeyPair]] = [:] - - public static func createClosedGroup(name: String, members: Set, transaction: YapDatabaseReadWriteTransaction) -> Promise { - // Prepare - var members = members - let userPublicKey = getUserHexEncodedPublicKey() - // 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 closedGroupControlMessageKind = ClosedGroupControlMessage.Kind.new(publicKey: Data(hex: groupPublicKey), name: name, - encryptionKeyPair: encryptionKeyPair, 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 - Storage.shared.addClosedGroupEncryptionKeyPair(encryptionKeyPair, for: groupPublicKey, using: transaction) - // Notify the PN server - 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) - // 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 - /// 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) - } - // Generate the new encryption key pair - let newKeyPair = Curve25519.generateKeyPair() - // Distribute it - let proto = try! SNProtoKeyPair.builder(publicKey: newKeyPair.publicKey, - privateKey: newKeyPair.privateKey).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] ?? [] - 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 } - } - - public static func update(_ groupPublicKey: String, with members: Set, name: String, 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 update nonexistent closed group.") - return Promise(error: Error.noThread) - } - let group = thread.groupModel - var promises: [Promise] = [] - let zombies = SNMessagingKitConfiguration.shared.storage.getZombieMembers(for: groupPublicKey) - // Update name if needed - if name != group.groupName { promises.append(setName(to: name, for: groupPublicKey, using: transaction)) } - // Add members if needed - let addedMembers = members.subtracting(group.groupMemberIds + zombies) - if !addedMembers.isEmpty { promises.append(addMembers(addedMembers, to: groupPublicKey, using: transaction)) } - // 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) - } - 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 - /// `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) - } - guard !newMembers.isEmpty else { - SNLog("Invalid closed group update.") - return Promise(error: Error.invalidClosedGroupUpdate) - } - 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) - // 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(()) - } - - /// 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 { - SNLog("Invalid closed group update.") - return Promise(error: Error.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 { - SNLog("Only an admin can remove members from a group.") - return Promise(error: Error.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) - // 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) - } - // Return - return promise - } - - @objc(leaveClosedGroupWithPublicKey:using:) - public static func objc_leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> AnyPromise { - return AnyPromise.from(leave(groupPublicKey, using: transaction)) - } - - /// 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. - /// - /// 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 { - SNLog("Can't leave nonexistent closed group.") - return Promise(error: Error.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) - } - }.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) - // 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) - // Return - return promise - } - - /* - public static func requestEncryptionKeyPair(for groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) throws { - #if DEBUG - preconditionFailure("Shouldn't currently be in use.") - #endif - // 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 request encryption key pair for nonexistent closed group.") - throw Error.noThread - } - let group = thread.groupModel - guard group.groupMemberIds.contains(getUserHexEncodedPublicKey()) else { return } - // Send the request to the group - let closedGroupControlMessage = ClosedGroupControlMessage(kind: .encryptionKeyPairRequest) - MessageSender.send(closedGroupControlMessage, in: thread, using: transaction) - } - */ - - 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 { - return SNLog("Couldn't send key pair for nonexistent closed group.") - } - let group = groupThread.groupModel - guard group.groupMemberIds.contains(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 } - // Send it - guard let proto = try? SNProtoKeyPair.builder(publicKey: encryptionKeyPair.publicKey, - privateKey: encryptionKeyPair.privateKey).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) - } -} diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift new file mode 100644 index 000000000..9942c3012 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -0,0 +1,225 @@ +// 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 StorageError.objectNotSaved } + + try prep(db, signalAttachments: attachments, for: interactionId) + 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, 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 StorageError.objectNotSaved } + + 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: threadId, + interactionId: interactionId, + details: MessageSendJob.Details( + destination: destination, + message: message + ) + ) + ) + } + + // 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: StorageError.objectNotSaved) } + + try prep(db, signalAttachments: attachments, for: interactionId) + + 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, 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 StorageError.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 sendNonDurably( + db, + message: message, + interactionId: interactionId, + to: try Message.Destination.from(db, thread: thread) + ) + } + + public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, to destination: Message.Destination) -> Promise { + 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 { + let threadId: String = { + switch destination { + case .contact(let publicKey): return publicKey + case .closedGroup(let groupPublicKey): return groupPublicKey + 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) + let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment + .stateInfo(interactionId: interactionId, state: .uploading) + .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() + + attachment.upload( + db, + queue: DispatchQueue.global(qos: .userInitiated), + using: { db, data in + if let openGroup: OpenGroup = openGroup { + return OpenGroupAPI + .uploadFile( + db, + bytes: data.bytes, + to: openGroup.roomToken, + on: openGroup.server + ) + .map { _, response -> String in response.id } + } + + return FileServerAPI.upload(data) + .map { response -> String in response.id } + }, + encrypt: (openGroup == nil), + success: { fileId in seal.fulfill(fileId) }, + 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 Storage.shared.writeAsync { db in + 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 + .with(fileIds: fileIds), + 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: StorageError.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 + .sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil) + .done { seal.fulfill(()) } + .catch { _ in seal.reject(StorageError.generic) } + .retainUntilComplete() + } + else { + JobRunner.add( + db, + job: Job( + variant: .messageSend, + details: MessageSendJob.Details( + destination: destination, + message: configurationMessage + ) + ) + ) + seal.fulfill(()) + } + + return promise + } +} diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index 6fee9e1c9..99f4e6765 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -1,18 +1,72 @@ -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 } - let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removing05PrefixIfNeeded()) - let sodium = Sodium() + 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 + } - let verificationData = plaintext + Data(userED25519KeyPair.publicKey) + recipientX25519PublicKey - 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 } + let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removingIdPrefixIfNeeded()) + + let verificationData = plaintext + Data(userEd25519KeyPair.publicKey) + recipientX25519PublicKey + guard let signature = dependencies.sign.signature(message: Bytes(verificationData), secretKey: userEd25519KeyPair.secretKey) else { + throw MessageSenderError.signingFailed + } + + let plaintextWithMetadata = plaintext + Data(userEd25519KeyPair.publicKey) + Data(signature) + guard let ciphertext = dependencies.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { + throw MessageSenderError.encryptionFailed + } return Data(ciphertext) } + + 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 + } + guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { + throw MessageSenderError.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 MessageSenderError.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 MessageSenderError.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 fd280e83c..438ffa55f 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -1,424 +1,723 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit import SessionSnodeKit import SessionUtilitiesKit +import Sodium -@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) { - guard let tsMessage = TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) else { - #if DEBUG - preconditionFailure() - #else - return - #endif - } - 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) - } +public final class MessageSender { + // MARK: - Preparation - @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 + public static func prep( + _ db: Database, + signalAttachments: [SignalAttachment], + for interactionId: Int64 + ) throws { + try signalAttachments.enumerated().forEach { index, 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( + albumIndex: index, + interactionId: interactionId, + attachmentId: attachment.id + ) + + try attachment.insert(db) + try interactionAttachment.insert(db) } - 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 - public static func send(_ message: Message, to destination: Message.Destination, using transaction: Any) -> Promise { + // MARK: - Convenience + + public static func sendImmediate(_ 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: + return sendToOpenGroupDestination(db, message: message, to: destination, interactionId: interactionId) + + case .openGroupInbox: + return sendToOpenGroupInboxDestination(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 = storage.getUserPublicKey() - var isMainAppAndActive = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppAndActive = sharedUserDefaults.bool(forKey: "isMainAppActive") - } + 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 - if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set - message.sentTimestamp = NSDate.millisecondTimestamp() - } + message.sentTimestamp = ( + message.sentTimestamp ?? // Visible messages will already have their sent timestamp set + UInt64(messageSendTimestamp) + ) 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, .openGroupInbox: 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 } - // 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 - // • a call message of type `answer` or `endCall` - guard !isSelfSend || isSyncMessage || shouldSyncMessage(message) else { - storage.write(with: { transaction in - MessageSender.handleSuccessfulMessageSend(message, to: destination, using: transaction) - seal.fulfill(()) - }, completion: { }) + guard message.isValid else { + handleFailure(db, with: .invalidMessage) return promise } + + // Stop here if this is a self-send, unless we should sync the message + let isSelfSend: Bool = (message.recipient == userPublicKey) + + guard + !isSelfSend || + isSyncMessage || + Message.shouldSync(message: message) + 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.VMProfile( + displayName: profile.name, + profileKey: profileKey, + profilePictureUrl: profilePictureUrl + ) + } + else { + message.profile = VisibleMessage.VMProfile(displayName: profile.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) + 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 { + SNLog("Couldn't serialize proto due to error: \(error).") + 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: encryptionKeyPair.hexEncodedPublicKey) - 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, threadId: groupPublicKey) else { + throw MessageSenderError.noKeyPair + } + + ciphertext = try encryptWithSessionProtocol( + plaintext, + for: SessionId(.standard, publicKey: encryptionKeyPair.publicKey.bytes).hexString + ) + + case .openGroup, .openGroupInbox: 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, .openGroupInbox: 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, - isClosedGroupMessage: (kind == .closedGroupMessage), - isConfigMessage: message.isKind(of: ConfigurationMessage.self)) - .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 callMessage = message as? CallMessage, case .preOffer = callMessage.kind { - shouldNotify = true - } - /* - 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: UInt64(messageSendTimestamp + SnodeAPI.clockOffset) + ) + + SnodeAPI + .sendMessage( + snodeMessage, + isClosedGroupMessage: (kind == .closedGroupMessage), + isConfigMessage: (message is ConfigurationMessage) + ) + .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: .default)) { responseData in + guard !isSuccess else { return } // Succeed as soon as the first promise succeeds + isSuccess = true + + Storage.shared.write { db in + let responseJson: JSON? = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON + message.serverHash = (responseJson?["hash"] as? String) + + try MessageSender.handleSuccessfulMessageSend( + db, + message: message, + to: destination, + interactionId: interactionId, + isSyncMessage: 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 { + 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, + queue: DispatchQueue.global(qos: .default), + 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: .default)) { error in + errorCount += 1 + guard errorCount == promiseCount else { return } // Only error out if all promises failed + + Storage.shared.read { 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: .default)) { error in + SNLog("Couldn't send message due to error: \(error).") + + Storage.shared.read { 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?, + dependencies: SMKDependencies = SMKDependencies() + ) -> Promise { let (promise, seal) = Promise.pending() - let storage = SNMessagingKitConfiguration.shared.storage - let transaction = transaction as! YapDatabaseReadWriteTransaction + let threadId: String + // 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 = storage.getUserPublicKey() + 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, .closedGroup, .openGroupInbox: preconditionFailure() + case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, _): + threadId = OpenGroup.idFor(roomToken: roomToken, server: server) + message.recipient = [ + server, + roomToken, + whisperTo, + (whisperMods ? "mods" : nil) + ] + .compactMap { $0 } + .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 { + seal.reject(MessageSenderError.invalidMessage) + return promise + } + + message.sender = { + let capabilities: [Capability.Variant] = (try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == server) + .filter(Capability.Columns.isMissing == false) + .asRequest(of: Capability.Variant.self) + .fetchAll(db)) + .defaulting(to: []) + + // If the server doesn't support blinding then go with an unblinded id + guard capabilities.contains(.blind) else { + return SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString + } + guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + preconditionFailure() + } + + return SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString + }() + // 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 let message = message as? VisibleMessage else { #if DEBUG preconditionFailure() #else - handleFailure(with: Error.invalidMessage, using: transaction) + handleFailure(db, with: MessageSenderError.invalidMessage) 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) - } - // 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) + guard message.isValid else { + handleFailure(db, with: .invalidMessage) 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) } + + // Attach the user's profile + message.profile = VisibleMessage.VMProfile( + profile: Profile.fetchOrCreateCurrentUser() + ) - 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: { }) + if (message.profile?.displayName ?? "").isEmpty { + handleFailure(db, with: .noUsername) + return promise } - // Return + + // Convert it to protobuf + 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 { + SNLog("Couldn't serialize proto due to error: \(error).") + handleFailure(db, with: .other(error)) + return promise + } + + // Send the result + OpenGroupAPI + .send( + db, + plaintext: plaintext, + to: roomToken, + on: server, + whisperTo: whisperTo, + whisperMods: whisperMods, + fileIds: fileIds, + using: dependencies + ) + .done(on: DispatchQueue.global(qos: .default)) { responseInfo, data in + 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 + try MessageSender.handleSuccessfulMessageSend( + db, + message: message, + to: destination, + interactionId: interactionId, + serverTimestampMs: UInt64(floor(data.posted * 1000)) + ) + seal.fulfill(()) + } + } + .catch(on: DispatchQueue.global(qos: .default)) { error in + dependencies.storage.read { db in + handleFailure(db, with: .other(error)) + } + } + + return promise + } + + internal static func sendToOpenGroupInboxDestination( + _ db: Database, + message: Message, + to destination: Message.Destination, + interactionId: Int64?, + dependencies: SMKDependencies = SMKDependencies() + ) -> Promise { + let (promise, seal) = Promise.pending() + let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + + 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 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) + } + + message.sender = userPublicKey + message.recipient = recipientBlindedPublicKey + + // Set the failure handler (need it here already for precondition failure handling) + func handleFailure(_ db: Database, with error: MessageSenderError) { + MessageSender.handleFailedMessageSend(db, message: message, with: error, interactionId: interactionId) + seal.reject(error) + } + + // Attach the user's profile if needed + 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.VMProfile( + displayName: profile.name, + profileKey: profileKey, + profilePictureUrl: profilePictureUrl + ) + } + else { + message.profile = VisibleMessage.VMProfile(displayName: profile.name) + } + } + + // Convert it to protobuf + 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 { + SNLog("Couldn't serialize proto due to error: \(error).") + handleFailure(db, with: .other(error)) + 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(db, with: .other(error)) + return promise + } + + // Send the result + OpenGroupAPI + .send( + db, + ciphertext: ciphertext, + toInboxFor: recipientBlindedPublicKey, + on: server, + using: dependencies + ) + .done(on: DispatchQueue.global(qos: .default)) { responseInfo, data in + message.openGroupServerMessageId = UInt64(data.id) + + dependencies.storage.write { transaction in + try MessageSender.handleSuccessfulMessageSend( + db, + message: message, + to: destination, + interactionId: interactionId + ) + seal.fulfill(()) + } + } + .catch(on: DispatchQueue.global(qos: .default)) { error in + dependencies.storage.read { 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) - NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil) + try interaction.recipientStates + .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent)) + // Start the disappearing messages timer if needed - OWSDisappearingMessagesJob.shared().startAnyExpiration(for: tsMessage, expirationStartedAt: NSDate.millisecondTimestamp(), transaction: transaction) - } - // Prevent the same ExpirationTimerUpdate to be handled twice - if let message = message as? ExpirationTimerUpdate { - Storage.shared.addReceivedMessageTimestamp(message.sentTimestamp!, using: transaction) + JobRunner.upsert( + db, + job: DisappearingMessagesJob.updateNextRunIfNeeded( + db, + interaction: interaction, + startedAtMs: (Date().timeIntervalSince1970 * 1000) + ) + ) } + + // 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 .openGroup(let roomToken, let server, _, _, _): + return OpenGroup.idFor(roomToken: roomToken, server: server) + + case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey + } + }(), + message: message, + serverExpirationTimestamp: (Date().timeIntervalSince1970 + ControlMessageProcessRecord.defaultExpirationSeconds) + )?.insert(db) + // Sync the message if: // • 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 } + // 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 } - // 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) + public static func handleFailedMessageSend( + _ db: Database, + message: Message, + with error: MessageSenderError, + interactionId: Int64? + ) { + // 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) + .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: Utils - private static func shouldSyncMessage(_ message: Message) -> Bool { - let isNewClosedGroupControlMessage = given(message as? ClosedGroupControlMessage) { - if case .new = $0.kind { - return true - } else { - return false - } } ?? false - let isCallControlMessage = given(message as? CallMessage) { - if case .answer = $0.kind { - return true - } else if case .endCall = $0.kind { - return true - } else { - return false - } } ?? false - return isNewClosedGroupControlMessage || isCallControlMessage || message is ConfigurationMessage || message is UnsendRequest + // 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) + } + + if let sentTimestamp: Double = message.sentTimestamp.map({ Double($0) }) { + return try Interaction + .filter(Interaction.Columns.timestampMs == sentTimestamp) + .fetchOne(db) + } + + 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 = Storage.shared.writeAsync { db in + try MessageSender.leave(db, groupPublicKey: groupPublicKey) + } + + return AnyPromise.from(promise) + } + + @objc(forceSyncConfigurationNow) + public static func objc_forceSyncConfigurationNow() { + Storage.shared.write { db in + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } } } diff --git a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift index 87cdcfd51..d8ceb8bba 100644 --- a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift @@ -1,10 +1,20 @@ +// 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 { @objc static let initialConfigurationMessageReceived = Notification.Name.initialConfigurationMessageReceived.rawValue as NSString + @objc static let incomingMessageMarkedAsRead = Notification.Name.incomingMessageMarkedAsRead.rawValue as NSString } 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/NotificationsProtocol.h b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.h deleted file mode 100644 index 576adb737..000000000 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.h +++ /dev/null @@ -1,33 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class TSErrorMessage; -@class TSIncomingMessage; -@class TSInfoMessage; -@class TSThread; -@class YapDatabaseReadTransaction; -@class YapDatabaseReadWriteTransaction; - -@protocol ContactsManagerProtocol; - -@protocol NotificationsProtocol - -- (void)notifyUserForIncomingMessage:(TSIncomingMessage *)incomingMessage - inThread:(TSThread *)thread - transaction:(YapDatabaseReadTransaction *)transaction; - -- (void)notifyUserForIncomingCall:(TSInfoMessage *)callInfoMessage - 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..0fefd991f --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift @@ -0,0 +1,11 @@ +// 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 notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) + func cancelNotifications(identifiers: [String]) + func clearAllNotifications() +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 8fccb96ec..11499c28f 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -1,12 +1,36 @@ -import SessionSnodeKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import GRDB import PromiseKit +import SessionSnodeKit +import SessionUtilitiesKit @objc(LKPushNotificationAPI) public final class PushNotificationAPI : NSObject { + 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 + } - // 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,39 +39,59 @@ 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: RegistrationRequestBody = RegistrationRequestBody(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 { - return SNLog("Couldn't unregister from push notifications.") + 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.code != 0 else { + return SNLog("Couldn't unregister from push notifications due to error: \(response.message ?? "nil").") + } } - guard json["code"] as? Int != 0 else { - return SNLog("Couldn't unregister from push notifications due to error: \(json["message"] as? String ?? "nil").") - } - } } 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) + Storage.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 } @@ -57,7 +101,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: RegistrationRequestBody = RegistrationRequestBody(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,31 +116,56 @@ 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" ] - 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 { - 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").") - } - userDefaults[.deviceToken] = hexEncodedToken - userDefaults[.lastDeviceTokenUpload] = now - userDefaults[.isUsingFullAPNs] = true + var request: URLRequest = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.httpBody = body + + var promises: [Promise] = [] + + promises.append( + attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { + OnionRequestAPI.sendOnionRequest(request, to: server, with: serverPublicKey) + .map2 { _, data -> Void in + guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.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 + } } - } - promise.catch2 { error in + ) + promises.first?.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) - } - return promise + 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 when(fulfilled: promises) } @objc(registerWithToken:hexEncodedPublicKey:isForcedUpdate:) @@ -101,20 +176,32 @@ 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( + closedGroupPublicKey: 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 { - return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") + 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.code != 0 else { + return SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey) due to error: \(response.message ?? "nil").") + } } - 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").") - } - } } promise.catch2 { error in SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") @@ -126,4 +213,40 @@ 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, 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 + } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index c25f6941e..e87643a1b 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -1,165 +1,289 @@ -import SessionSnodeKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit +import SessionSnodeKit +import SessionUtilitiesKit -@objc(LKClosedGroupPoller) -public final class ClosedGroupPoller : NSObject { - private var isPolling: Atomic<[String:Bool]> = Atomic([:]) - private var timers: [String:Timer] = [:] +public final class ClosedGroupPoller { + private var isPolling: Atomic<[String: Bool]> = Atomic([:]) + private var timers: [String: Timer] = [:] - // 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 don't contain the current user as a + // GroupMemeber as the user is no longer a member of those) + 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) + } + .defaulting(to: []) + .forEach { [weak self] groupPublicKey in + self?.startPolling(for: groupPublicKey) + } } 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. - isPolling.mutate{ $0[groupPublicKey] = true } + isPolling.mutate { $0[groupPublicKey] = true } setUpPolling(for: groupPublicKey) } - @objc public func stop() { - let storage = SNMessagingKitConfiguration.shared.storage - let allGroupPublicKeys = storage.getUserClosedGroupPublicKeys() - allGroupPublicKeys.forEach { stopPolling(for: $0) } + public func stopAllPollers() { + let pollers: [String] = Array(isPolling.wrappedValue.keys) + + pollers.forEach { groupPublicKey in + self.stopPolling(for: groupPublicKey) + } } public func stopPolling(for groupPublicKey: String) { - isPolling.mutate{ $0[groupPublicKey] = false } + isPolling.mutate { $0[groupPublicKey] = false } timers[groupPublicKey]?.invalidate() } - // MARK: Private API + // MARK: - Private API + private func setUpPolling(for groupPublicKey: String) { Threading.pollerQueue.async { - let promises: [Promise] = { - if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 { - return [ self.poll(groupPublicKey) ] + ClosedGroupPoller.poll(groupPublicKey, poller: self) + .done(on: Threading.pollerQueue) { [weak self] _ in + self?.pollRecursively(groupPublicKey) } - if SnodeAPI.hardfork >= 19 { - return [ self.poll(groupPublicKey, defaultInbox: true), self.poll(groupPublicKey) ] + .catch(on: Threading.pollerQueue) { [weak self] error in + // The error is logged in poll(_:) + self?.pollRecursively(groupPublicKey) } - return [ self.poll(groupPublicKey, defaultInbox: true) ] - }() - when(resolved: promises).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.wrappedValue[groupPublicKey] == true, + 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 = - (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 = Storage.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.") + timers[groupPublicKey] = Timer.scheduledTimerOnMainThread(withTimeInterval: nextPollInterval, repeats: false) { [weak self] timer in timer.invalidate() - Threading.pollerQueue.async { - let promises: [Promise] = { - if SnodeAPI.hardfork >= 19 && SnodeAPI.softfork >= 1 { - return [ self?.poll(groupPublicKey) ].compactMap{ $0 } - } - if SnodeAPI.hardfork >= 19 { - return [ self?.poll(groupPublicKey, defaultInbox: true), self?.poll(groupPublicKey) ].compactMap{ $0 } - } - return [ self?.poll(groupPublicKey, defaultInbox: true) ].compactMap{ $0 } - }() - when(resolved: promises).done(on: Threading.pollerQueue) { _ in - self?.pollRecursively(groupPublicKey) - }.catch(on: Threading.pollerQueue) { error in - // The error is logged in poll(_:) - self?.pollRecursively(groupPublicKey) - } - } - } - } - - private func poll(_ groupPublicKey: String, defaultInbox: Bool = false) -> Promise { - guard isPolling(for: groupPublicKey) else { return Promise.value(()) } - let promise = SnodeAPI.getSwarm(for: groupPublicKey).then2 { [weak self] swarm -> Promise<(Snode, [JSON], JSON?)> 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) } - let getRawMessagesPromise = defaultInbox ? SnodeAPI.getRawClosedGroupMessagesFromDefaultNamespace(from: snode, associatedWith: groupPublicKey) : SnodeAPI.getRawMessages(from: snode, associatedWith: groupPublicKey, authenticated: false) - return getRawMessagesPromise.map2 { - let (rawMessages, lastRawMessage) = SnodeAPI.parseRawMessagesResponse($0, from: snode, associatedWith: groupPublicKey) - - return (snode, rawMessages, lastRawMessage) - } - } - promise.done2 { [weak self] snode, rawMessages, lastRawMessage 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).") - } - var processedMessages: [JSON] = [] - rawMessages.forEach { json in - guard let envelope = SNProtoEnvelope.from(json) else { return } - do { - let data = try envelope.serializedData() - let job = MessageReceiveJob(data: data, serverHash: json["hash"] as? String, isBackgroundPoll: false) - SNMessagingKitConfiguration.shared.storage.write { transaction in - SessionMessagingKit.JobQueue.shared.add(job, using: transaction) - } - processedMessages.append(json) - } catch { - SNLog("Failed to deserialize envelope due to error: \(error).") - } - } - // Now that the MessageReceiveJob's have been created we can update the `lastMessageHash` value & `receivedMessageHashes` - SnodeAPI.updateLastMessageHashValueIfPossible(for: snode, namespace: SnodeAPI.closedGroupNamespace, associatedWith: groupPublicKey, from: lastRawMessage) - SnodeAPI.updateReceivedMessages(from: processedMessages, associatedWith: groupPublicKey) + Threading.pollerQueue.async { + ClosedGroupPoller.poll(groupPublicKey, poller: self) + .done(on: Threading.pollerQueue) { _ in + self?.pollRecursively(groupPublicKey) + } + .catch(on: Threading.pollerQueue) { error in + // The error is logged in poll(_:) + self?.pollRecursively(groupPublicKey) + } + } } - promise.catch2 { error in - SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).") - } - return promise.map { _ in } } + + public static func poll( + _ groupPublicKey: String, + 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) + .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) } + + return attempt(maxRetryCount: maxRetryCount, recoveringOn: queue) { + 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 { + 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 && 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 { + case .fulfilled(let messages): return result.appending(contentsOf: messages) + default: return result + } + } + var messageCount: Int = 0 + + // 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 + 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: + guard !isBackgroundPoll else { break } + + SNLog("Failed to the database being suspended (running in background with no background task).") + break - // MARK: Convenience - private func isPolling(for groupPublicKey: String) -> Bool { - return isPolling.wrappedValue[groupPublicKey] ?? false + default: SNLog("Failed to deserialize envelope due to error: \(error).") + } + + return nil + } + } + + messageCount = processedMessages.count + + jobToRun = 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) + } + + 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) + } + } + } + + 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 new file mode 100644 index 000000000..fc4b15eb1 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -0,0 +1,484 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import PromiseKit +import SessionSnodeKit +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 + private var isPolling = false + + // MARK: - Settings + + private static let minPollInterval: TimeInterval = 3 + private static let maxPollInterval: Double = (60 * 60) + internal static let maxInactivityPeriod: Double = (14 * 24 * 60 * 60) + + // MARK: - Lifecycle + + public init(for server: String) { + self.server = server + } + + public func startIfNeeded(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { + guard !hasStarted else { return } + + hasStarted = true + pollRecursively(using: dependencies) + } + + @objc public func stop() { + timer?.invalidate() + hasStarted = false + } + + // 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) + } + + @discardableResult + public func poll( + isBackgroundPoll: Bool, + isBackgroundPollerValid: @escaping (() -> Bool) = { true }, + isPostCapabilitiesRetry: Bool, + using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + ) -> Promise { + guard !self.isPolling else { return Promise.value(()) } + + self.isPolling = true + let server: String = self.server + let (promise, seal) = Promise.pending() + promise.retainUntilComplete() + + Threading.pollerQueue.async { + dependencies.storage + .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, + hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true, + timeSinceLastPoll: ( + dependencies.cache.timeSinceLastPoll[server] ?? + dependencies.cache.getTimeSinceLastOpen(using: dependencies) + ), + using: dependencies + ) + .map(on: OpenGroupAPI.workQueue) { (failureCount, $0) } + } + .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, + failureCount: failureCount, + isBackgroundPoll: isBackgroundPoll, + using: dependencies + ) + + dependencies.mutableCache.mutate { cache in + cache.hasPerformedInitialPoll[server] = true + 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 + 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( + server: server, + isBackgroundPoll: isBackgroundPoll, + isPostCapabilitiesRetry: isPostCapabilitiesRetry, + error: error + ) + .done(on: OpenGroupAPI.workQueue) { [weak self] didHandleError in + if !didHandleError { + // 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 + 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: PollResponse, + failureCount: Int64, + isBackgroundPoll: Bool, + using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + ) { + let server: String = self.server + let validResponses: PollResponse = response + .filter { endpoint, endpointResponse in + switch endpoint { + case .capabilities: + guard (endpointResponse.data as? BatchSubResponse)?.body != nil else { + SNLog("Open group polling failed due to invalid capability data.") + 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, + on: server + ) + + case .roomPollInfo(let roomToken, _): + guard + let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, + let responseBody: RoomPollInfo = responseData.body + else { return } + + try OpenGroupManager.handlePollInfo( + db, + pollInfo: responseBody, + publicKey: nil, + for: roomToken, + on: server, + dependencies: dependencies + ) + + 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 { return } + + OpenGroupManager.handleMessages( + db, + messages: responseBody.compactMap { $0.value }, + for: roomToken, + on: server, + isBackgroundPoll: isBackgroundPoll, + dependencies: dependencies + ) + + case .inbox, .inboxSince, .outbox, .outboxSince: + 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 ?? []) ?? []) + let fromOutbox: Bool = { + switch endpoint { + case .outbox, .outboxSince: return true + default: return false + } + }() + + OpenGroupManager.handleDirectMessages( + db, + messages: messages, + fromOutbox: fromOutbox, + on: server, + isBackgroundPoll: isBackgroundPoll, + dependencies: dependencies + ) + + default: break // No custom handling needed + } + } + } + } + } + + // 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/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift deleted file mode 100644 index 930361510..000000000 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPollerV2.swift +++ /dev/null @@ -1,112 +0,0 @@ -import PromiseKit - -@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 } - hasStarted = true - timer = Timer.scheduledTimerOnMainThread(withTimeInterval: pollInterval, repeats: true) { _ in - self.poll().retainUntilComplete() - } - 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() - 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 - } - } - return promise - } - - private func handleCompactPollBody(_ body: OpenGroupAPIV2.CompactPollResponseBody, 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 - 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 = OpenGroupAPIV2.moderators[server] { - x[body.room] = Set(body.moderators) - OpenGroupAPIV2.moderators[server] = x - } else { - OpenGroupAPIV2.moderators[server] = [ body.room : Set(body.moderators) ] - } - - // - Deletions - guard !body.deletions.isEmpty else { return } - - let deletedMessageServerIDs = Set(body.deletions.map { UInt64($0.deletedMessageID) }) - storage.write { transaction in - guard let transaction: YapDatabaseReadWriteTransaction = transaction as? YapDatabaseReadWriteTransaction else { return } - - deletedMessageServerIDs.forEach { openGroupServerMessageId in - guard let messageLookup: OpenGroupServerIdLookup = storage.getOpenGroupServerIdLookup(openGroupServerMessageId, in: body.room, on: self.server, using: transaction) else { - return - } - guard let tsMessage: TSMessage = TSMessage.fetch(uniqueId: messageLookup.tsMessageId, transaction: transaction) else { return } - - tsMessage.remove(with: transaction) - storage.removeOpenGroupServerIdLookup(openGroupServerMessageId, in: body.room, on: self.server, using: transaction) - } - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 5a97e1129..4877077d2 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -1,131 +1,204 @@ -import SessionSnodeKit -import PromiseKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -@objc(LKPoller) -public final class Poller : NSObject { - private let storage = OWSPrimaryStorage.shared() - private var isPolling = false +import Foundation +import GRDB +import PromiseKit +import Sodium +import SessionSnodeKit +import SessionUtilitiesKit + +public final class Poller { + 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 + private static let maxRetryInterval: TimeInterval = 15 + /// After polling a given snode this many times we always switch to a new one. /// /// The reason for doing this is that sometimes a snode will be giving us successful responses while /// it isn't actually getting messages from other snodes. private static let maxPollCount: UInt = 6 - // MARK: Error - private enum Error : LocalizedError { + // 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 - @objc public func startIfNeeded() { - guard !isPolling else { return } + // MARK: - Public API + + public init() {} + + public func startIfNeeded() { + guard !isPolling.wrappedValue else { return } + SNLog("Started polling.") - isPolling = true + isPolling.mutate { $0 = true } setUpPolling() } - @objc public func stop() { + public func stop() { SNLog("Stopped polling.") - isPolling = false + isPolling.mutate { $0 = false } usedSnodes.removeAll() } - // 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() - } - } - } + // MARK: - Private API + + private func setUpPolling(delay: TimeInterval = Poller.retryInterval) { + 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 + } + .done(on: Threading.pollerQueue) { [weak self] in + guard self?.isPolling.wrappedValue == true else { return } + + Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.retryInterval, repeats: false) { _ in + self?.setUpPolling() + } + } + .catch(on: Threading.pollerQueue) { [weak self] _ in + guard self?.isPolling.wrappedValue == true else { return } + + let nextDelay: TimeInterval = min(Poller.maxRetryInterval, (delay * 1.2)) + Timer.scheduledTimerOnMainThread(withTimeInterval: nextDelay, repeats: false) { _ in + self?.setUpPolling() + } + } + } } private func pollNextSnode(seal: Resolver) { let userPublicKey = getUserHexEncodedPublicKey() let swarm = SnodeAPI.swarmCache[userPublicKey] ?? [] - let unusedSnodes = Set(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 { + let unusedSnodes = swarm.subtracting(usedSnodes) + + 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 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) } + 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(()) } } - 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) - if !messages.isEmpty { - SNLog("Received \(messages.count) new message(s).") - } - var processedMessages: [JSON] = [] - messages.forEach { json in - guard let envelope = SNProtoEnvelope.from(json) else { return } - do { - let data = try envelope.serializedData() - let job = MessageReceiveJob(data: data, serverHash: json["hash"] as? String, isBackgroundPoll: false) - SNMessagingKitConfiguration.shared.storage.write { transaction in - SessionMessagingKit.JobQueue.shared.add(job, using: transaction) + guard isPolling.wrappedValue else { return Promise { $0.fulfill(()) } } + + let userPublicKey: String = getUserHexEncodedPublicKey() + + return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey) + .then(on: Threading.pollerQueue) { [weak self] messages -> Promise in + guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } } + + if !messages.isEmpty { + var messageCount: Int = 0 + + Storage.shared.write { db in + 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 + + 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).") + } + + return nil + } + } + .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 + ) + ) + ) + } } - processedMessages.append(json) - } catch { - SNLog("Failed to deserialize envelope due to error: \(error).") + + SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))") } - } - - // Now that the MessageReceiveJob's have been created we can update the `lastMessageHash` value & `receivedMessageHashes` - SnodeAPI.updateLastMessageHashValueIfPossible(for: snode, namespace: SnodeAPI.defaultNamespace, associatedWith: userPublicKey, from: lastRawMessage) - SnodeAPI.updateReceivedMessages(from: processedMessages, associatedWith: userPublicKey) - - strongSelf.pollCount += 1 - if strongSelf.pollCount == Poller.maxPollCount { - throw Error.pollLimitReached - } else { + else { + SNLog("Received no new messages") + } + + self?.pollCount += 1 + + guard (self?.pollCount ?? 0) < Poller.maxPollCount else { + throw Error.pollLimitReached + } + 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/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 59dada4d7..000000000 --- a/SessionMessagingKit/Sending & Receiving/Quotes/OWSQuotedReplyModel.m +++ /dev/null @@ -1,236 +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 -#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/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift new file mode 100644 index 000000000..9e688faaa --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -0,0 +1,76 @@ +// 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 contentType: String? + public let sourceFileName: String? + public let thumbnailDownloadFailed: Bool + + // MARK: - Initialization + + init( + threadId: String, + authorId: String, + timestampMs: Int64, + body: String?, + attachment: Attachment?, + contentType: String?, + sourceFileName: String?, + thumbnailDownloadFailed: Bool + ) { + self.attachment = attachment + self.threadId = threadId + self.authorId = authorId + self.timestampMs = timestampMs + self.body = body + self.contentType = contentType + self.sourceFileName = sourceFileName + self.thumbnailDownloadFailed = thumbnailDownloadFailed + } + + public static func quotedReplyForSending( + threadId: String, + authorId: String, + variant: Interaction.Variant, + body: String?, + timestampMs: Int64, + attachments: [Attachment]?, + 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: targetAttachment, + contentType: targetAttachment?.contentType, + sourceFileName: targetAttachment?.sourceFilename, + thumbnailDownloadFailed: false + ) + } +} + +// MARK: - Convenience + +public extension QuotedReplyModel { + func generateAttachmentThumbnailIfNeeded(_ db: Database) throws -> String? { + guard let sourceAttachment: Attachment = self.attachment else { return nil } + + return try sourceAttachment + .cloneAsQuoteThumbnail()? + .inserted(db) + .id + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift b/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift deleted file mode 100644 index 9cdf974d1..000000000 --- a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage+Conversion.swift +++ /dev/null @@ -1,31 +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 } - let result = VisibleMessage.Quote() - result.timestamp = quote.timestamp - result.publicKey = quote.authorId - result.text = quote.body - result.attachmentID = quote.quotedAttachments.first?.attachmentId - return result - } -} 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 d69b971e0..000000000 --- a/SessionMessagingKit/Sending & Receiving/Quotes/TSQuotedMessage.m +++ /dev/null @@ -1,340 +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 "TSThread.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/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/Sending & Receiving/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index c532e6231..cdda8b025 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -1,388 +1,214 @@ -// -// 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 threadId: String + fileprivate let direction: Direction + fileprivate let timestampMs: Int64 + + fileprivate var refreshTimer: Timer? + fileprivate var stopTimer: Timer? + + 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 Storage.shared[.typingIndicatorsEnabled] && !threadIsMessageRequest else { + return nil } - guard let startedTypingTimestamp = incomingIndicators.startedTypingTimestamp else { - continue + + // Don't send typing indicators in group threads + guard threadVariant != .closedGroup && threadVariant != .openGroup else { return nil } + + self.threadId = threadId + self.direction = direction + self.timestampMs = (timestampMs ?? Int64(floor(Date().timeIntervalSince1970 * 1000))) + } + + fileprivate func start(_ db: Database) { + // Start the typing indicator + switch direction { + case .outgoing: + scheduleRefreshCallback(db, shouldSend: (refreshTimer == nil)) + + case .incoming: + try? ThreadTypingIndicator( + threadId: threadId, + timestampMs: timestampMs + ) + .save(db) } - 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 + + // Refresh the timeout since we just started + refreshTimeout() } - 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 - self.thread = thread - } - - // 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: - } - - 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 - } - // `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 - } - - 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) + + fileprivate func stop(_ db: Database) { + self.refreshTimer?.invalidate() + self.refreshTimer = nil + self.stopTimer?.invalidate() + self.stopTimer = nil + + switch direction { + case .outgoing: + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: self.threadId) else { + return + } + + try? MessageSender.send( + db, + message: TypingIndicator(kind: .stopped), + interactionId: nil, + in: thread + ) + + case .incoming: + _ = try? ThreadTypingIndicator + .filter(ThreadTypingIndicator.Columns.threadId == self.threadId) + .deleteAll(db) } } - } - - // 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() + + fileprivate func refreshTimeout() { + let threadId: String = self.threadId + let direction: Direction = self.direction + + // 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) } } } - - init(delegate: TypingIndicators, thread: TSThread, - recipientId: String, deviceId: UInt) { - self.delegate = delegate - self.thread = thread - self.recipientId = recipientId - self.deviceId = deviceId - } - - 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() + + 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), + interactionId: nil, + in: thread + ) } - 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 + + refreshTimer?.invalidate() + refreshTimer = Timer.scheduledTimerOnMainThread( + withTimeInterval: 10, + repeats: false + ) { [weak self] _ in + Storage.shared.write { db in + self?.scheduleRefreshCallback(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 - } - guard let threadId = thread.uniqueId else { - return - } - NotificationCenter.default.postNotificationNameAsync(TypingIndicatorsImpl.typingIndicatorStateDidChange, object: threadId) + } + } + + // 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 didStartTypingNeedsToStart( + threadId: String, + threadVariant: SessionThread.Variant, + threadIsMessageRequest: Bool, + direction: Direction, + timestampMs: Int64? + ) -> Bool { + switch direction { + case .outgoing: + // 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 + } + + 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: + // 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 + } + + 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: + if let indicator: Indicator = outgoing.wrappedValue[threadId] { + indicator.stop(db) + outgoing.mutate { $0[threadId] = nil } + } + + case .incoming: + if let indicator: Indicator = incoming.wrappedValue[threadId] { + indicator.stop(db) + incoming.mutate { $0[threadId] = nil } + } } } } 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/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift new file mode 100644 index 000000000..bad4cb96e --- /dev/null +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -0,0 +1,839 @@ +// 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 +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) + 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 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) + 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 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 + + 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 rawBody: String? + public let expiresStartedAtMs: Double? + public let expiresInSeconds: TimeInterval? + + public let state: RecipientState.State + public let hasAtLeastOneReadReceipt: Bool + public let mostRecentFailureText: String? + public let isSenderOpenGroupModerator: Bool + public let isTypingIndicator: Bool? + public let profile: Profile? + public let quote: Quote? + public let quoteAttachment: Attachment? + public let linkPreview: LinkPreview? + public let linkPreviewAttachment: Attachment? + + public let currentUserPublicKey: String + + // 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 + /// + /// **Note:** This will only be populated for incoming messages + 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 + + /// This is the users blinded key (will only be set for messages within open groups) + public let currentUserBlindedPublicKey: String? + + // MARK: - Mutation + + 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, + timestampMs: self.timestampMs, + 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, + isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + isTypingIndicator: self.isTypingIndicator, + profile: self.profile, + quote: self.quote, + quoteAttachment: self.quoteAttachment, + linkPreview: self.linkPreview, + linkPreviewAttachment: self.linkPreviewAttachment, + currentUserPublicKey: self.currentUserPublicKey, + 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, + currentUserBlindedPublicKey: self.currentUserBlindedPublicKey + ) + } + + public func withClusteringChanges( + prevModel: MessageViewModel?, + nextModel: MessageViewModel?, + isLast: Bool, + currentUserBlindedPublicKey: String? + ) -> MessageViewModel { + let cellType: CellType = { + guard self.isTypingIndicator != true 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 != 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( + between: prevModel.timestampMs, + and: self.timestampMs + ) + }() + let shouldShowDateOnNextModel: Bool = { + // Should be nothing after a typing indicator + guard self.isTypingIndicator != true 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( + 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, + 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, + 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( + id: firstAttachment.id, + variant: firstAttachment.variant, + contentType: firstAttachment.contentType, + sourceFilename: firstAttachment.sourceFilename + ) + }, + attachmentCount: self.attachments?.count, + isOpenGroupInvitation: (self.linkPreview?.variant == .openGroupInvitation) + ) + ), + rawBody: self.body, + expiresStartedAtMs: self.expiresStartedAtMs, + expiresInSeconds: self.expiresInSeconds, + state: self.state, + hasAtLeastOneReadReceipt: self.hasAtLeastOneReadReceipt, + mostRecentFailureText: self.mostRecentFailureText, + isSenderOpenGroupModerator: self.isSenderOpenGroupModerator, + isTypingIndicator: self.isTypingIndicator, + profile: self.profile, + quote: self.quote, + quoteAttachment: self.quoteAttachment, + linkPreview: self.linkPreview, + linkPreviewAttachment: self.linkPreviewAttachment, + currentUserPublicKey: self.currentUserPublicKey, + attachments: self.attachments, + cellType: cellType, + authorName: authorDisplayName, + senderName: { + // Only show for group threads + 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 { + 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, isn't a standard message or has a "date break" + ( + self.authorId != nextModel?.authorId || + (nextModel?.variant != .standardIncoming && nextModel?.variant != .standardIncomingDeleted) || + 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, + currentUserBlindedPublicKey: currentUserBlindedPublicKey + ) + } +} + +// 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: - 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 { + static let genericId: Int64 = -1 + static let typingIndicatorId: Int64 = -2 + + // 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 + + let targetId: Int64 = (isTypingIndicator == true ? + MessageViewModel.typingIndicatorId : + MessageViewModel.genericId + ) + self.rowId = targetId + self.id = targetId + self.variant = .standardOutgoing + self.timestampMs = Int64.max + 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.isSenderOpenGroupModerator = false + self.isTypingIndicator = isTypingIndicator + self.profile = nil + self.quote = nil + self.quoteAttachment = nil + self.linkPreview = nil + self.linkPreviewAttachment = nil + self.currentUserPublicKey = "" + + // 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 + self.currentUserBlindedPublicKey = nil + } +} + +// 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 + +// MARK: --MessageViewModel + +public extension MessageViewModel { + static func filterSQL(threadId: String) -> SQL { + let interaction: TypedTableAlias = TypedTableAlias() + + 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( + userPublicKey: String, + orderSQL: SQL, + groupSQL: SQL? + ) -> (([Int64]) -> AdaptedFetchRequest>) { + return { rowIds -> 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() + let profile: TypedTableAlias = TypedTableAlias() + 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) + 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 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), + -- Default to 'false' when no contact exists + 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]), + \(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(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), + (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), + \(recipientState[.mostRecentFailureText]) AS \(ViewModel.mostRecentFailureTextKey), + + ( + \(groupMemberModeratorTableLiteral).\(groupMemberProfileIdColumnLiteral) IS NOT NULL OR + \(groupMemberAdminTableLiteral).\(groupMemberProfileIdColumnLiteral) IS NOT NULL + ) AS \(ViewModel.isSenderOpenGroupModeratorKey), + + \(ViewModel.profileKey).*, + \(ViewModel.quoteKey).*, + \(ViewModel.quoteAttachmentKey).*, + \(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), + '' 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 \(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]) + 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.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) + ) + 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)")) + ) + WHERE \(interaction.alias[Column.rowID]) IN \(rowIds) + \(groupSQL ?? "") + ORDER BY \(orderSQL) + """ + + 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] + ]) + } + } + } +} + +// MARK: --AttachmentInteractionInfo + +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[.interactionId]) = \(interaction[.id]) + JOIN \(Attachment.self) ON \(attachment[.id]) = \(interactionAttachment[.attachmentId]) + """ + }() + + 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 + } + } +} + +// 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 \(ThreadTypingIndicator.self) ON \(threadTypingIndicator[.threadId]) = \(interaction[.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 new file mode 100644 index 000000000..bb78b6940 --- /dev/null +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -0,0 +1,1512 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import DifferenceKit +import SessionUtilitiesKit + +fileprivate typealias ViewModel = SessionThreadViewModel + +/// 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: 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) + 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 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 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) + 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) + 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) + + public static let threadUnreadCountString: String = CodingKeys.threadUnreadCount.stringValue + public static let threadUnreadMentionCountString: String = CodingKeys.threadUnreadMentionCount.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 var id: String { threadId } + + public let rowId: Int64 + public let threadId: String + public let threadVariant: SessionThread.Variant + private let threadCreationDateTimestamp: TimeInterval + public let threadMemberNames: String? + + public let threadIsNoteToSelf: 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 let 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? + + // 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 openGroupRoomToken: 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 threadContactNameInternal: String? + private let authorNameInternal: String? + public let currentUserPublicKey: String + public let currentUserBlindedPublicKey: 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 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 + /// 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 SessionThreadViewModel { + static let invalidId: String = "INVALID_THREAD_ID" + + // Note: This init method is only used system-created cells or empty states + init( + threadId: String? = nil, + threadVariant: SessionThread.Variant? = nil, + currentUserIsClosedGroupMember: Bool? = nil, + unreadCount: UInt = 0 + ) { + self.rowId = -1 + self.threadId = (threadId ?? SessionThreadViewModel.invalidId) + self.threadVariant = (threadVariant ?? .contact) + self.threadCreationDateTimestamp = 0 + 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 + + // Thread display info + + self.contactProfile = nil + self.closedGroupProfileFront = nil + self.closedGroupProfileBack = nil + self.closedGroupProfileBackFallback = nil + self.closedGroupName = nil + self.closedGroupUserCount = nil + self.currentUserIsClosedGroupMember = currentUserIsClosedGroupMember + self.currentUserIsClosedGroupAdmin = nil + self.openGroupName = nil + self.openGroupServer = nil + self.openGroupRoomToken = nil + self.openGroupProfilePictureData = nil + self.openGroupUserCount = 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.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 + ) + ) + ) + } +} + +// MARK: - HomeVC & MessageRequestsViewController + +// MARK: --SessionThreadViewModel + +public extension SessionThreadViewModel { + static func baseQuery( + userPublicKey: String, + 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 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) + 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(\(ViewModel.contactProfileKey).\(profileNicknameColumnLiteral), \(ViewModel.contactProfileKey).\(profileNameColumnLiteral)) AS \(ViewModel.threadContactNameInternalKey), + 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) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + 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) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + 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 interaction: TypedTableAlias = TypedTableAlias() + + return """ + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + 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]) + """ + }() + + static func homeFilterSQL(userPublicKey: String) -> SQL { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + + 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 messageRequestsFilterSQL(userPublicKey: String) -> SQL { + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + + 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]) * 1000)) DESC") + }() + + static let messageRequetsOrderSQL: SQL = { + let thread: TypedTableAlias = TypedTableAlias() + + return SQL("IFNULL(\(Interaction.self).\(ViewModel.interactionTimestampMsKey), (\(thread[.creationDateTimestamp]) * 1000)) DESC") + }() +} + +// MARK: - ConversationVC + +public extension SessionThreadViewModel { + static func conversationQuery(threadId: String, 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 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 = 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), + + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), + ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND + IFNULL(\(contact[.isApproved]), false) = false + ) AS \(ViewModel.threadIsMessageRequestKey), + ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + 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), + + \(Interaction.self).\(ViewModel.threadUnreadCountKey), + + \(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[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey), + \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), + + \(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) + + 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 \(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 + \(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)")) + + 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] + ]) + } + } + + 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 profile: 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 = 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), + + 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) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + 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) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + 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 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 + /// + /// 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 StorageError.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.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 = 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), + + (\(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) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + 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) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + 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 \(Column.rank), \(interaction[.timestampMs].desc) + LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) + """ + + 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.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 + /// `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 = 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), + \(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) + + """ + + // MARK: --Contact Threads + let contactQueryCommonJoinFilterGroup: SQL = """ + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + 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 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 += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + ) + """ + 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]), + 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) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + 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) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + 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 thread name searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(closedGroupFullTextSearch) ON ( + \(closedGroupFullTextSearch).rowid = \(closedGroupLiteral).rowid AND + \(closedGroupFullTextSearch).\(closedGroupNameColumnLiteral) MATCH \(pattern) + ) + """ + sqlQuery += closedGroupQueryCommonJoinFilterGroup + + // Closed group member nickname searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND + \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + ) + """ + sqlQuery += closedGroupQueryCommonJoinFilterGroup + + // Closed group member name searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + JOIN \(Profile.self) AS \(groupMemberProfileLiteral) ON \(groupMemberProfileLiteral).\(profileIdColumnLiteral) = \(groupMember[.profileId]) + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(groupMemberProfileLiteral).rowid AND + \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + ) + """ + sqlQuery += closedGroupQueryCommonJoinFilterGroup + + // MARK: --Open Group Threads + // 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]) + """ + + // MARK: --Note to Self Thread + let noteToSelfQueryCommonJoins: SQL = """ + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) + 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 + """ + + // 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 + '\(noteToSelfLiteral)' LIKE '%\(searchTermLiteral)%' + """ + + // Note to self thread nickname searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + ) + """ + sqlQuery += noteToSelfQueryCommonJoins + sqlQuery += """ + + WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) + """ + + // Note to self thread name searching + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += selectQuery + sqlQuery += """ + + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + ) + """ + sqlQuery += noteToSelfQueryCommonJoins + sqlQuery += """ + + 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 + \(Column.rank), + \(ViewModel.threadIsNoteToSelfKey), + \(ViewModel.closedGroupNameKey), + \(ViewModel.openGroupNameKey), + \(ViewModel.threadIdKey) + LIMIT \(SQL("\(SessionThreadViewModel.searchResultsLimit)")) + """ + + // 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] + ]) + } + } + + /// 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 = 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), + '' 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 + +public extension SessionThreadViewModel { + 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 profile: 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.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 = 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), + + (\(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]) = \(thread[.id]) + + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( + \(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 + \(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) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + 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]) * 1000)) 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/Storage.swift b/SessionMessagingKit/Storage.swift deleted file mode 100644 index aba77646a..000000000 --- a/SessionMessagingKit/Storage.swift +++ /dev/null @@ -1,106 +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: - General - - func getUserPublicKey() -> String? - func getUserKeyPair() -> ECKeyPair? - func getUserED25519KeyPair() -> Box.KeyPair? - func getUser() -> Contact? - func getUser(using transaction: YapDatabaseReadTransaction?) -> Contact? - func getAllContacts() -> Set - func getAllContacts(with transaction: YapDatabaseReadTransaction) -> Set - - // 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? - 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) - - // 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) - - // MARK: - Calls - - func getReceivedCalls(for publicKey: String, using transaction: Any) -> Set - func setReceivedCalls(to receivedCalls: Set, for publicKey: String, using transaction: Any) -} - -extension Storage: SessionMessagingKitStorageProtocol, SessionSnodeKitStorageProtocol {} diff --git a/SessionMessagingKit/Threads/Notification+Thread.swift b/SessionMessagingKit/Threads/Notification+Thread.swift deleted file mode 100644 index 4b61f8f1b..000000000 --- a/SessionMessagingKit/Threads/Notification+Thread.swift +++ /dev/null @@ -1,14 +0,0 @@ - -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/Threads/TSContactThread.h b/SessionMessagingKit/Threads/TSContactThread.h deleted file mode 100644 index f3e6aa085..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 NS_SWIFT_NAME(fetch(for:using:)); - -- (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 dba2333dd..000000000 --- a/SessionMessagingKit/Threads/TSContactThread.m +++ /dev/null @@ -1,129 +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; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID]; - - return ( - self.shouldBeVisible && - !self.isNoteToSelf && ( - contact == nil || - !contact.isApproved - ) - ); -} - -- (BOOL)isMessageRequestUsingTransaction:(YapDatabaseReadTransaction *)transaction { - NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID using:transaction]; - - return ( - self.shouldBeVisible && - !self.isNoteToSelf && ( - contact == nil || - !contact.isApproved - ) - ); -} - -- (BOOL)isBlocked { - NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID]; - - return (contact.isBlocked == YES); -} - -- (BOOL)isBlockedUsingTransaction:(YapDatabaseReadTransaction *)transaction { - NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID using:transaction]; - - return (contact.isBlocked == YES); -} - -- (BOOL)isGroupThread -{ - return NO; -} - -- (NSString *)name -{ - NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID]; - return [contact displayNameFor:SNContactContextRegular] ?: sessionID; -} - -- (NSString *)nameWithTransaction:(YapDatabaseReadTransaction *)transaction -{ - NSString *sessionID = self.contactSessionID; - SNContact *contact = [LKStorage.shared getContactWithSessionID:sessionID using:transaction]; - return [contact displayNameFor:SNContactContextRegular] ?: 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 2ad8f00bf..000000000 --- a/SessionMessagingKit/Threads/TSGroupModel.m +++ /dev/null @@ -1,161 +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) { - SNContact *contact = [LKStorage.shared getContactWithSessionID:publicKey]; - return [contact displayNameFor:SNContactContextRegular] ?: 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) { - SNContact *contact = [LKStorage.shared getContactWithSessionID:publicKey]; - return [contact displayNameFor:SNContactContextRegular] ?: 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 b6629794e..000000000 --- a/SessionMessagingKit/Threads/TSThread.h +++ /dev/null @@ -1,142 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -BOOL IsNoteToSelfEnabled(void); - -@class OWSDisappearingMessagesConfiguration; -@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 (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; - -- (NSUInteger)unreadMessageCountWithTransaction:(YapDatabaseReadTransaction *)transaction - NS_SWIFT_NAME(unreadMessageCount(transaction:)); - -/** - * @return If there is any message mentioning current user in this thread. - */ -- (NSUInteger)unreadMentionMessageCount; - -- (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. - * - * @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 889dde49a..000000000 --- a/SessionMessagingKit/Threads/TSThread.m +++ /dev/null @@ -1,458 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "TSThread.h" -#import "OWSDisappearingMessagesConfiguration.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 (nonatomic, copy, nullable) NSString *messageDraft; -@property (atomic, nullable) NSDate *mutedUntilDate; - -@end - -@implementation TSThread - -#pragma mark Dependencies - -- (TSAccountManager *)tsAccountManager -{ - return SSKEnvironment.shared.tsAccountManager; -} - -#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]; -} - -- (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; - [[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]; -} - -- (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; - __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 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 { - 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/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 5cd1dcec2..000000000 --- a/SessionMessagingKit/To Do/OWSRecipientIdentity.m +++ /dev/null @@ -1,116 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSRecipientIdentity.h" -#import "OWSIdentityManager.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 b45356ac5..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 b01b70503..000000000 --- a/SessionMessagingKit/To Do/OWSUserProfile.m +++ /dev/null @@ -1,90 +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 - -NSString *const kNSNotificationName_LocalProfileDidChange = @"kNSNotificationName_LocalProfileDidChange"; -NSString *const kNSNotificationName_OtherUsersProfileDidChange = @"kNSNotificationName_OtherUsersProfileDidChange"; -NSString *const kNSNotificationKey_ProfileRecipientId = @"kNSNotificationKey_ProfileRecipientId"; - -@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/To Do/ProfileManagerProtocol.h b/SessionMessagingKit/To Do/ProfileManagerProtocol.h deleted file mode 100644 index 6dfb991ab..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.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 eed562917..000000000 --- a/SessionMessagingKit/To Do/TSAccountManager.m +++ /dev/null @@ -1,500 +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 "ProfileManagerProtocol.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/Data+Utilities.swift b/SessionMessagingKit/Utilities/Data+Utilities.swift new file mode 100644 index 000000000..c04495f6a --- /dev/null +++ b/SessionMessagingKit/Utilities/Data+Utilities.swift @@ -0,0 +1,24 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +// MARK: - Decoding + +extension Dependencies { + static let userInfoKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "io.oxen.dependencies.codingOptions")! +} + +public extension Data { + func decoded(as type: T.Type, using dependencies: Dependencies = Dependencies()) throws -> T { + do { + let decoder: JSONDecoder = JSONDecoder() + decoder.userInfo = [ Dependencies.userInfoKey: dependencies ] + + return try decoder.decode(type, from: self) + } + catch { + throw HTTP.Error.parsingFailed + } + } +} 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/Environment.h b/SessionMessagingKit/Utilities/Environment.h deleted file mode 100644 index edbd77376..000000000 --- a/SessionMessagingKit/Utilities/Environment.h +++ /dev/null @@ -1,42 +0,0 @@ -#import - -@class OWSAudioSession; -@class OWSPreferences; -@class OWSSounds; -@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 - 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; - -@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 81a39ccf2..000000000 --- a/SessionMessagingKit/Utilities/Environment.m +++ /dev/null @@ -1,66 +0,0 @@ - -#import -#import "OWSWindowManager.h" -#import -#import "OWSPreferences.h" -#import "OWSSounds.h" - -static Environment *sharedEnvironment = nil; - -@interface Environment () - -@property (nonatomic) OWSAudioSession *audioSession; -@property (nonatomic) OWSPreferences *preferences; -@property (nonatomic) id proximityMonitoringManager; -@property (nonatomic) OWSSounds *sounds; -@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 - sounds:(OWSSounds *)sounds - windowManager:(OWSWindowManager *)windowManager -{ - self = [super init]; - - if (!self) { - return self; - } - - _audioSession = audioSession; - _preferences = preferences; - _proximityMonitoringManager = proximityMonitoringManager; - _sounds = sounds; - _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..3c0bc1ff5 --- /dev/null +++ b/SessionMessagingKit/Utilities/Environment.swift @@ -0,0 +1,62 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public class Environment { + public static var shared: Environment? + + public let reachabilityManager: SSKReachabilityManager + + public let audioSession: OWSAudioSession + public let proximityMonitoringManager: OWSProximityMonitoringManager + 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) + + public var isComplete: Bool { + (notificationsManager.wrappedValue != nil) + } + + // MARK: - Initialization + + public init( + reachabilityManager: SSKReachabilityManager, + audioSession: OWSAudioSession, + proximityMonitoringManager: OWSProximityMonitoringManager, + windowManager: OWSWindowManager + ) { + self.reachabilityManager = reachabilityManager + self.audioSession = audioSession + self.proximityMonitoringManager = proximityMonitoringManager + self.windowManager = windowManager + self.isRequestingPermission = false + + if Environment.shared == nil { + Environment.shared = self + } + } + + // MARK: - Functions + + public static func clearSharedForTests() { + shared = nil + } +} + +// MARK: - Objective C Support + +@objc(SMKEnvironment) +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 isRequestingPermission: Bool { (Environment.shared?.isRequestingPermission == true) } +} diff --git a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift b/SessionMessagingKit/Utilities/FullTextSearchFinder.swift deleted file mode 100644 index 0320aeaf2..000000000 --- a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift +++ /dev/null @@ -1,251 +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 - 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 - } - - 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/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/MessageInvalidator.swift b/SessionMessagingKit/Utilities/MessageInvalidator.swift deleted file mode 100644 index 83619e091..000000000 --- a/SessionMessagingKit/Utilities/MessageInvalidator.swift +++ /dev/null @@ -1,27 +0,0 @@ - -/// 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/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/OWSAudioPlayer.h b/SessionMessagingKit/Utilities/OWSAudioPlayer.h index 09d207ad1..759086b5e 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; @@ -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; @@ -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..49a272b63 100644 --- a/SessionMessagingKit/Utilities/OWSAudioPlayer.m +++ b/SessionMessagingKit/Utilities/OWSAudioPlayer.m @@ -3,7 +3,6 @@ // #import "OWSAudioPlayer.h" -#import "TSAttachmentStream.h" #import #import #import @@ -61,7 +60,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithMediaUrl:(NSURL *)mediaUrl audioBehavior:(OWSAudioBehavior)audioBehavior - delegate:(id)delegate + delegate:(nullable id)delegate { self = [super init]; if (!self) { @@ -95,7 +94,7 @@ NS_ASSUME_NONNULL_BEGIN - (OWSAudioSession *)audioSession { - return Environment.shared.audioSession; + return SMKEnvironment.shared.audioSession; } #pragma mark diff --git a/SessionMessagingKit/Utilities/OWSAudioSession.swift b/SessionMessagingKit/Utilities/OWSAudioSession.swift index 1f9653118..cbc5b547f 100644 --- a/SessionMessagingKit/Utilities/OWSAudioSession.swift +++ b/SessionMessagingKit/Utilities/OWSAudioSession.swift @@ -1,9 +1,9 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import AVFoundation +import SignalCoreKit +import SessionUtilitiesKit @objc(OWSAudioActivity) public class AudioActivity: NSObject { @@ -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/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/OWSIdentityManager.h b/SessionMessagingKit/Utilities/OWSIdentityManager.h deleted file mode 100644 index d283170d6..000000000 --- a/SessionMessagingKit/Utilities/OWSIdentityManager.h +++ /dev/null @@ -1,60 +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; - -// 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; - -- (nullable ECKeyPair *)identityKeyPair; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/OWSIdentityManager.m b/SessionMessagingKit/Utilities/OWSIdentityManager.m deleted file mode 100644 index 17b041673..000000000 --- a/SessionMessagingKit/Utilities/OWSIdentityManager.m +++ /dev/null @@ -1,266 +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"; - -// 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; - - return self; -} - -- (void)dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -#pragma mark - - -- (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]; -} - -- (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; - } -} - -@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/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 deleted file mode 100644 index 8fdafd1b1..000000000 --- a/SessionMessagingKit/Utilities/OWSPreferences.h +++ /dev/null @@ -1,110 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import -#import -#import -#import -#import - -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 OWSPreferencesKeyEnableDebugLog; -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)isReadyForAppExtensions; -+ (void)setIsReadyForAppExtensions; - -- (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; - -- (void)setIOSUpgradeNagDate:(NSDate *)value; -- (nullable NSDate *)iOSUpgradeNagDate; - -- (BOOL)hasGeneratedThumbnails; -- (void)setHasGeneratedThumbnails:(BOOL)value; - -- (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; - -#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 deleted file mode 100644 index 29def0003..000000000 --- a/SessionMessagingKit/Utilities/OWSPreferences.m +++ /dev/null @@ -1,438 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "OWSPreferences.h" - -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"; -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"; -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 - -- (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)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)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]; - if (preference) { - return [preference boolValue]; - } else { - return NO; - } -} - -+ (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)]; -} - -- (BOOL)hasDeclinedNoContactsView -{ - NSNumber *preference = [self tryGetValueForKey:OWSPreferencesKeyHasDeclinedNoContactsView]; - // Default to NO. - return preference ? [preference boolValue] : NO; -} - -- (void)setHasDeclinedNoContactsView:(BOOL)value -{ - [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]; -} - -- (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)]; -} - -#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/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 bd51d9345..000000000 --- a/SessionMessagingKit/Utilities/OWSSounds.swift +++ /dev/null @@ -1,10 +0,0 @@ -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/OWSWindowManager.m b/SessionMessagingKit/Utilities/OWSWindowManager.m index 9083d9fb4..f257a09d3 100644 --- a/SessionMessagingKit/Utilities/OWSWindowManager.m +++ b/SessionMessagingKit/Utilities/OWSWindowManager.m @@ -3,7 +3,7 @@ // #import "OWSWindowManager.h" -#import "Environment.h" +#import #import NS_ASSUME_NONNULL_BEGIN @@ -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. @@ -153,7 +138,7 @@ const UIWindowLevel UIWindowLevel_MessageActions(void) + (instancetype)sharedManager { - return Environment.shared.windowManager; + return SMKEnvironment.shared.windowManager; } - (instancetype)initDefault @@ -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/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift new file mode 100644 index 000000000..868a37cc2 --- /dev/null +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -0,0 +1,462 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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) + 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 appSwitcherPreviewEnabled: Setting.BoolKey = "appSwitcherPreviewEnabled" + + /// 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 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 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" + + /// 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" + + /// 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 { + /// 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 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 + 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() + } + } + } + + public enum Sound: Int, Codable, DatabaseValueConvertible, EnumSetting { + 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` + + // 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 + + public 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 + ] + } + + 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 + } + } + + 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 + +// 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 Storage.shared[.preferencesNotificationPreviewType] + .defaulting(to: Preferences.NotificationPreviewType.nameAndPreview) + .rawValue + } + + @objc public static func setNotificationPreviewType(_ previewType: Int) { + Storage.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(setPlayNotificationSoundInForeground:) + static func objc_setPlayNotificationSoundInForeground(_ enabled: Bool) { + Storage.shared.write { db in db[.playNotificationSoundInForeground] = enabled } + } + + @objc(playNotificationSoundInForeground) + static func objc_playNotificationSoundInForeground() -> Bool { + return Storage.shared[.playNotificationSoundInForeground] + } + + @objc(setScreenSecurity:) + static func objc_setScreenSecurity(_ enabled: Bool) { + Storage.shared.write { db in db[.appSwitcherPreviewEnabled] = enabled } + } + + @objc(isScreenSecurityEnabled) + static func objc_isScreenSecurityEnabled() -> Bool { + return Storage.shared[.appSwitcherPreviewEnabled] + } + + @objc(setAreReadReceiptsEnabled:) + static func objc_setAreReadReceiptsEnabled(_ enabled: Bool) { + Storage.shared.write { db in db[.areReadReceiptsEnabled] = enabled } + } + + @objc(areReadReceiptsEnabled) + static func objc_areReadReceiptsEnabled() -> Bool { + return Storage.shared[.areReadReceiptsEnabled] + } + + @objc(setTypingIndicatorsEnabled:) + static func objc_setTypingIndicatorsEnabled(_ enabled: Bool) { + Storage.shared.write { db in db[.typingIndicatorsEnabled] = enabled } + } + + @objc(areTypingIndicatorsEnabled) + static func objc_areTypingIndicatorsEnabled() -> Bool { + return Storage.shared[.typingIndicatorsEnabled] + } + + @objc(setLinkPreviewsEnabled:) + static func objc_setLinkPreviewsEnabled(_ enabled: Bool) { + Storage.shared.write { db in db[.areLinkPreviewsEnabled] = enabled } + } + + @objc(areLinkPreviewsEnabled) + static func objc_areLinkPreviewsEnabled() -> Bool { + return Storage.shared[.areLinkPreviewsEnabled] + } + + @objc(setCallsEnabled:) + static func objc_setCallsEnabled(_ enabled: Bool) { + Storage.shared.write { db in db[.areCallsEnabled] = enabled } + } + + @objc(areCallsEnabled) + static func objc_areCallsEnabled() -> Bool { + return Storage.shared[.areCallsEnabled] + } +} + +@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 { + return Storage.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 } + + Storage.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 (Storage.shared + .read { db in + try Preferences.Sound + .fetchOne( + db, + SessionThread + .select(.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 } + + 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 new file mode 100644 index 000000000..42c5fd0dd --- /dev/null +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -0,0 +1,403 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import PromiseKit +import SignalCoreKit +import SessionUtilitiesKit + +public struct ProfileManager { + // 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: Data]> = 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(_ db: Database? = nil, id: String) -> Data? { + guard let db: Database = db else { + return Storage.shared.read { db in profileAvatar(db, id: id) } + } + guard let profile: Profile = try? Profile.fetchOne(db, id: id) else { return nil } + + return profileAvatar(profile: profile) + } + + public static func profileAvatar(profile: Profile) -> Data? { + if let profileFileName: String = profile.profilePictureFileName, !profileFileName.isEmpty { + return loadProfileAvatar(for: profileFileName, profile: profile) + } + + if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty { + downloadAvatar(for: profile) + } + + return nil + } + + 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 + 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] = data } + return data + } + + private static func loadProfileData(with fileName: String) -> Data? { + let filePath: String = ProfileManager.profileAvatarFilepath(filename: 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: - File Paths + + public static let sharedDataProfileAvatarsDirPath: String = { + let path: String = URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) + .appendingPathComponent("ProfileAvatars") + .path + OWSFileSystem.ensureDirectoryExists(path) + + return path + }() + + private static let profileAvatarsDirPath: String = { + let path: String = ProfileManager.sharedDataProfileAvatarsDirPath + OWSFileSystem.ensureDirectoryExists(path) + + 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 "" } + + return URL(fileURLWithPath: sharedDataProfileAvatarsDirPath) + .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) { + guard !currentAvatarDownloads.wrappedValue.contains(profile.id) else { + // Download already in flight; ignore + return + } + guard let profileUrlStringAtStart: String = profile.profilePictureUrl else { + SNLog("Skipping downloading avatar for \(profile.id) because url is not set") + return + } + guard + let fileId: String = Attachment.fileId(for: profileUrlStringAtStart), + let profileKeyAtStart: OWSAES256Key = profile.profileEncryptionKey, + profileKeyAtStart.keyData.count > 0 + else { + 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) + + queue.async { + OWSLogger.verbose("downloading profile avatar: \(profile.id)") + currentAvatarDownloads.mutate { $0.insert(profile.id) } + + let useOldServer: Bool = (profileUrlStringAtStart.contains(FileServerAPI.oldServer)) + + FileServerAPI + .download(fileId, useOldServer: useOldServer) + .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 + _ = try? Profile + .filter(id: profile.id) + .updateAll(db, Profile.Columns.profilePictureFileName.set(to: fileName)) + profileAvatarCache.mutate { $0[fileName] = decryptedData } + } + + // Redundant but without reading 'backgroundTask' it will warn that the variable + // 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() + } + } + + // MARK: - Current User Profile + + public static func updateLocal( + queue: DispatchQueue, + profileName: String, + image: UIImage?, + imageFilePath: String?, + requiredSync: Bool, + success: ((Database, Profile) throws -> ())? = nil, + failure: ((ProfileManagerError) -> ())? = nil + ) { + 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() + let maxAvatarBytes: UInt = (5 * 1000 * 1000) + let avatarImageData: Data? + + 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) + + 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.") + + try success?(db, updatedProfile) + } + return + } + + // If we have a new avatar image, we must first: + // + // * 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 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 + do { try data.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) } + catch { + SNLog("Updating service with profile failed.") + failure?(.avatarWriteFailed) + return + } + + // Encrypt the avatar for upload + guard let encryptedAvatarData: Data = encryptProfileData(data: data, key: newProfileKey) else { + SNLog("Updating service with profile failed.") + failure?(.avatarEncryptionFailed) + return + } + + // Upload the avatar to the FileServer + FileServerAPI + .upload(encryptedAvatarData) + .done(on: queue) { fileUploadResponse in + let downloadUrl: String = "\(FileServerAPI.server)/files/\(fileUploadResponse.id)" + UserDefaults.standard[.lastProfilePictureUpload] = Date() + + Storage.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] = data } + + SNLog("Successfully updated service with profile.") + try success?(db, profile) + } + } + .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/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/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift new file mode 100644 index 000000000..ed2f23f4b --- /dev/null +++ b/SessionMessagingKit/Utilities/Promise+Utilities.swift @@ -0,0 +1,29 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 { + self.map(on: queue) { data -> R in + try data.decoded(as: type, using: dependencies) + } + } +} + +extension Promise where T == (OnionRequestResponseInfoType, Data?) { + 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 } + + do { + return (responseInfo, try data.decoded(as: type, using: dependencies)) + } + catch { + throw HTTP.Error.parsingFailed + } + } + } +} 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.. - -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 0a1ac6874..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:self.localProfileKey.keyData]; - } -} - -+ (void)addLocalProfileKeyToDataMessageBuilder:(SNProtoDataMessageBuilder *)dataMessageBuilder -{ - [dataMessageBuilder setProfileKey:self.localProfileKey.keyData]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift b/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift index 5dff015a7..2739f356a 100644 --- a/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift +++ b/SessionMessagingKit/Utilities/ProximityMonitoringManager.swift @@ -1,6 +1,8 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SignalCoreKit +import SessionUtilitiesKit @objc public protocol OWSProximityMonitoringManager: AnyObject { diff --git a/SessionMessagingKit/Utilities/SMKDependencies.swift b/SessionMessagingKit/Utilities/SMKDependencies.swift new file mode 100644 index 000000000..f7b8f4498 --- /dev/null +++ b/SessionMessagingKit/Utilities/SMKDependencies.swift @@ -0,0 +1,97 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionSnodeKit +import SessionUtilitiesKit + +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 _sodium: SodiumType? + public var sodium: SodiumType { + get { Dependencies.getValueSettingIfNull(&_sodium) { Sodium() } } + set { _sodium = 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? + public var sign: SignType { + get { Dependencies.getValueSettingIfNull(&_sign) { sodium.getSign() } } + set { _sign = newValue } + } + + internal var _aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? + public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { + get { Dependencies.getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } + set { _aeadXChaCha20Poly1305Ietf = newValue } + } + + internal var _ed25519: Ed25519Type? + public var ed25519: Ed25519Type { + get { Dependencies.getValueSettingIfNull(&_ed25519) { Ed25519Wrapper() } } + set { _ed25519 = newValue } + } + + internal var _nonceGenerator16: NonceGenerator16ByteType? + public var nonceGenerator16: NonceGenerator16ByteType { + get { Dependencies.getValueSettingIfNull(&_nonceGenerator16) { OpenGroupAPI.NonceGenerator16Byte() } } + set { _nonceGenerator16 = newValue } + } + + internal var _nonceGenerator24: NonceGenerator24ByteType? + public var nonceGenerator24: NonceGenerator24ByteType { + get { Dependencies.getValueSettingIfNull(&_nonceGenerator24) { OpenGroupAPI.NonceGenerator24Byte() } } + set { _nonceGenerator24 = newValue } + } + + // MARK: - Initialization + + public init( + onionApi: OnionRequestAPIType.Type? = nil, + generalCache: Atomic? = nil, + storage: Storage? = nil, + sodium: SodiumType? = nil, + box: BoxType? = nil, + genericHash: GenericHashType? = nil, + sign: SignType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + ed25519: Ed25519Type? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) { + _onionApi = onionApi + _sodium = sodium + _box = box + _genericHash = genericHash + _sign = sign + _aeadXChaCha20Poly1305Ietf = aeadXChaCha20Poly1305Ietf + _ed25519 = ed25519 + _nonceGenerator16 = nonceGenerator16 + _nonceGenerator24 = nonceGenerator24 + + super.init( + generalCache: generalCache, + storage: storage, + standardUserDefaults: standardUserDefaults, + date: date + ) + } +} diff --git a/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift b/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift index b186b8cf5..5cb291067 100644 --- a/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift +++ b/SessionMessagingKit/Utilities/SNProtoEnvelope+Conversion.swift @@ -1,15 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit +import SessionUtilitiesKit 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 deleted file mode 100644 index d44250910..000000000 --- a/SessionMessagingKit/Utilities/SSKEnvironment.h +++ /dev/null @@ -1,82 +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)initWithProfileManager:(id)profileManager - primaryStorage:(OWSPrimaryStorage *)primaryStorage - identityManager:(OWSIdentityManager *)identityManager - 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 2c9046129..000000000 --- a/SessionMessagingKit/Utilities/SSKEnvironment.m +++ /dev/null @@ -1,141 +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) id profileManager; -@property (nonatomic) OWSPrimaryStorage *primaryStorage; -@property (nonatomic) OWSIdentityManager *identityManager; -@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)initWithProfileManager:(id)profileManager - primaryStorage:(OWSPrimaryStorage *)primaryStorage - identityManager:(OWSIdentityManager *)identityManager - 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; - } - - _profileManager = profileManager; - _primaryStorage = primaryStorage; - _identityManager = identityManager; - _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/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/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/Sodium+Utilities.swift new file mode 100644 index 000000000..bdc48469f --- /dev/null +++ b/SessionMessagingKit/Utilities/Sodium+Utilities.swift @@ -0,0 +1,287 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Clibsodium +import Sodium +import Curve25519Kit +import SessionUtilitiesKit + +/// 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(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 + + /// 64-byte blake2b hash then reduce to get the blinding factor + 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 { + return nil + } + + /// Reduce the server public key into an ed25519 scalar (`k`) + let kPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) + + _ = serverPublicKeyHashBytes.withUnsafeBytes { (serverPublicKeyHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let serverPublicKeyHashBaseAddress: UnsafePointer = serverPublicKeyHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_reduce(kPtr, serverPublicKeyHashBaseAddress) + return 0 + } + + 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) + func generatePrivateKeyScalar(secretKey: Bytes) -> Bytes { + /// a = s.to_curve25519_private_key().encode() + let aPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarMultLength) + + /// 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 // Impossible case (refer to comments at top of extension) + } + + return crypto_sign_ed25519_sk_to_curve25519(aPtr, secretKeyBaseAddress) + } + + 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, 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) + + _ = 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 // Impossible case (refer to comments at top of extension) + } + guard let aBaseAddress: UnsafePointer = aPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_mul(kaPtr, kBaseAddress, aBaseAddress) + return 0 + } + } + + 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) + + _ = combinedHashBytes.withUnsafeBytes { (combinedHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let combinedHashBaseAddress: UnsafePointer = combinedHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_reduce(rPtr, combinedHashBaseAddress) + return 0 + } + + /// 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) + + _ = HRAMHashBytes.withUnsafeBytes { (HRAMHashPtr: UnsafeRawBufferPointer) -> Int32 in + guard let HRAMHashBaseAddress: UnsafePointer = HRAMHashPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + Sodium.lib_crypto_core_ed25519_scalar_reduce(HRAMPtr, HRAMHashBaseAddress) + return 0 + } + + /// 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) + + _ = ka.withUnsafeBytes { (kaPtr: UnsafeRawBufferPointer) -> Int32 in + guard let kaBaseAddress: UnsafePointer = kaPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + 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 + } + + /// full_sig = sig_R + sig_s + return (Data(bytes: sig_RPtr, count: Sodium.noClampLength).bytes + Data(bytes: sig_sPtr, count: Sodium.scalarLength).bytes) + } + + /// 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 // Impossible case (refer to comments at top of extension) + } + guard let rhsKeyBytesBaseAddress: UnsafePointer = rhsKeyBytesPtr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { + return -1 // Impossible case (refer to comments at top of extension) + } + + return Sodium.lib_crypto_scalarmult_ed25519_noclamp(combinedPtr, lhsKeyBytesBaseAddress, rhsKeyBytesBaseAddress) + } + } + + /// Ensure the above worked + guard result == 0 else { return nil } + + 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? { + 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, 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, 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) + /// + /// 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 { + 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 + } +} + +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 + } +} + +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/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/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/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/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 03722a874..000000000 --- a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.h +++ /dev/null @@ -1,42 +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; -- (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 c95a67a1a..000000000 --- a/SessionMessagingKit/Utilities/YapDatabaseTransaction+OWS.m +++ /dev/null @@ -1,110 +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; -} - -- (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/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..44d87d22d --- /dev/null +++ b/SessionMessagingKitTests/Common Networking/RequestSpec.swift @@ -0,0 +1,168 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble +import SessionUtilitiesKit + +@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/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/Open Groups/Models/BatchRequestInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift new file mode 100644 index 000000000..97fbebfdb --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift @@ -0,0 +1,386 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionSnodeKit +import SessionUtilitiesKit + +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/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..966a270c4 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift @@ -0,0 +1,84 @@ +// 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", + roomToken: "room", + publicKey: "1234", + isActive: true, + name: "name", + 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 describing") { + it("includes relevant information") { + let openGroup: OpenGroup = OpenGroup( + server: "server", + roomToken: "room", + publicKey: "1234", + isActive: true, + name: "name", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ) + + expect(openGroup.description) + .to(equal("name (Server: server, Room: room)")) + } + } + + context("when describing in debug") { + it("includes relevant information") { + let openGroup: OpenGroup = OpenGroup( + server: "server", + roomToken: "room", + publicKey: "1234", + isActive: true, + name: "name", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ) + + expect(openGroup.debugDescription) + .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/RoomPollInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/RoomPollInfoSpec.swift new file mode 100644 index 000000000..4d5156ef3 --- /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", + roomDescription: 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..859cff307 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift @@ -0,0 +1,284 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class SOGSMessageSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a SOGSMessage") { + var messageJson: String! + var messageData: Data! + var decoder: JSONDecoder! + var mockSign: MockSign! + var mockEd25519: MockEd25519! + var dependencies: SMKDependencies! + + beforeEach { + messageJson = """ + { + "id": 123, + "session_id": "05\(TestConstants.publicKey)", + "posted": 234, + "seqno": 345, + "whisper": false, + "whisper_mods": false, + + "data": "VGVzdERhdGE=", + "signature": "VGVzdFNpZ25hdHVyZQ==" + } + """ + messageData = messageJson.data(using: .utf8)! + mockSign = MockSign() + mockEd25519 = MockEd25519() + dependencies = SMKDependencies( + 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 = """ + { + "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": "VGVzdFNpZ25hdHVyZQ==" + } + """ + 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": "VGVzdFNpZ25hdHVyZQ==" + } + """ + 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": "VGVzdFNpZ25hdHVyZQ==" + } + """ + 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": "VGVzdFNpZ25hdHVyZQ==" + } + """ + messageData = messageJson.data(using: .utf8)! + } + + it("succeeds if it succeeds verification") { + mockSign + .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + .thenReturn(true) + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .toNot(beNil()) + } + + it("provides the correct values as parameters") { + mockSign + .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + .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") { + mockSign + .when { $0.verify(message: anyArray(), publicKey: anyArray(), signature: anyArray()) } + .thenReturn(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") { + mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(true) + + expect { + try decoder.decode(OpenGroupAPI.Message.self, from: messageData) + } + .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") { + mockEd25519.when { try $0.verifySignature(any(), publicKey: any(), data: any()) }.thenReturn(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 new file mode 100644 index 000000000..a67c9c3ee --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -0,0 +1,3148 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import PromiseKit +import GRDB +import Sodium +import SessionSnodeKit +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class OpenGroupAPISpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var mockStorage: Storage! + var mockSodium: MockSodium! + var mockAeadXChaCha20Poly1305Ietf: MockAeadXChaCha20Poly1305Ietf! + var mockSign: MockSign! + var mockGenericHash: MockGenericHash! + var mockEd25519: MockEd25519! + var mockNonce16Generator: MockNonce16Generator! + var mockNonce24Generator: MockNonce24Generator! + var dependencies: SMKDependencies! + + var response: (OnionRequestResponseInfoType, Codable)? = nil + var pollResponse: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? + var error: Error? + + describe("an OpenGroupAPI") { + // MARK: - Configuration + + beforeEach { + mockStorage = Storage( + customWriter: DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNMessagingKit.migrations() + ] + ) + mockSodium = MockSodium() + mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() + mockSign = MockSign() + mockGenericHash = MockGenericHash() + mockNonce16Generator = MockNonce16Generator() + mockNonce24Generator = MockNonce24Generator() + mockEd25519 = MockEd25519() + dependencies = SMKDependencies( + onionApi: TestOnionRequestAPI.self, + storage: mockStorage, + sodium: mockSodium, + genericHash: mockGenericHash, + sign: mockSign, + aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, + ed25519: mockEd25519, + nonceGenerator16: mockNonce16Generator, + nonceGenerator24: mockNonce24Generator, + date: Date(timeIntervalSince1970: 1234567890) + ) + + 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, + isActive: true, + name: "Test", + roomDescription: nil, + imageId: nil, + userCount: 0, + infoUpdates: 0, + sequenceNumber: 0, + inboxLatestMessageId: 0, + outboxLatestMessageId: 0 + ).insert(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + + mockGenericHash.when { $0.hash(message: anyArray(), 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: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + } + .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 { + mockStorage = nil + mockSodium = nil + mockAeadXChaCha20Poly1305Ietf = nil + mockSign = nil + mockGenericHash = nil + mockEd25519 = nil + dependencies = nil + + response = nil + pollResponse = nil + error = nil + } + + // MARK: - Batching & Polling + + context("when polling") { + context("and given a correct response") { + 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 + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + + dependencies = dependencies.with(onionApi: TestApi.self) + } + + it("generates the correct request") { + 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() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + 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("testRoom", 0))) + expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) + expect(pollResponse?[.capabilities]?.0).to(beAKindOf(TestOnionRequestAPI.ResponseInfo.self)) + + // Validate request data + 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")) + expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + } + + it("retrieves recent messages if there was no last message") { + 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() + + 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") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) + } + + 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() + + 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") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) + } + + 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() + + 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") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: 123)) + } + + 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() + + expect(pollResponse) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + expect(pollResponse?.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) + } + + context("when unblinded") { + beforeEach { + 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") { + 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() + + 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)) + } + } + + 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.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") { + 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() + + 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)) + } + + it("retrieves recent inbox messages if there was no last message") { + 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() + + 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") { + mockStorage.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.inboxLatestMessageId.set(to: 124)) + } + + 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() + + 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") { + 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() + + 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.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.outboxLatestMessageId.set(to: 125)) + } + + 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() + + 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) + + 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() + + 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") { + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(pollResponse).to(beNil()) + } + + it("errors when invalid data is returned") { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + 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() + + 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 TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return "[]".data(using: .utf8) } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + 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() + + 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 TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return "{}".data(using: .utf8) } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + 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() + + 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 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 + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + 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() + + 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 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(onionApi: TestApi.self) + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities)? + + mockStorage + .read { db in + OpenGroupAPI.capabilities( + db, + server: "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(TestApi.data)) + + // 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")) + } + } + + // MARK: - Rooms + + context("when doing a rooms request") { + it("generates the request and handles the response correctly") { + class TestApi: TestOnionRequestAPI { + static let data: [OpenGroupAPI.Room] = [ + OpenGroupAPI.Room( + token: "test", + name: "test", + roomDescription: 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(onionApi: TestApi.self) + + var response: (info: OnionRequestResponseInfoType, data: [OpenGroupAPI.Room])? + + mockStorage + .read { db in + OpenGroupAPI.rooms( + db, + server: "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(TestApi.data)) + + // 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")) + } + } + + // MARK: - CapabilitiesAndRoom + + context("when doing a capabilitiesAndRoom request") { + context("and given a correct response") { + it("generates the request and handles the response correctly") { + class TestApi: TestOnionRequestAPI { + static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) + static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "test", + name: "test", + roomDescription: 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) + } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + + mockStorage + .read { db in + OpenGroupAPI.capabilitiesAndRoom( + db, + 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(TestApi.capabilitiesData)) + expect(response?.room.data).to(equal(TestApi.roomData)) + + // 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")) + } + } + + context("and given an invalid response") { + it("errors when only a capabilities response is returned") { + class TestApi: TestOnionRequestAPI { + static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) + + 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(onionApi: TestApi.self) + + var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + + mockStorage + .read { db in + OpenGroupAPI + .capabilitiesAndRoom( + db, + 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()) + } + + it("errors when only a room response is returned") { + class TestApi: TestOnionRequestAPI { + static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "test", + name: "test", + roomDescription: 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(onionApi: TestApi.self) + + var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + + mockStorage + .read { db in + OpenGroupAPI + .capabilitiesAndRoom( + db, + 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()) + } + + it("errors when an extra response is returned") { + class TestApi: TestOnionRequestAPI { + static let capabilitiesData: OpenGroupAPI.Capabilities = OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: nil) + static let roomData: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "test", + name: "test", + roomDescription: 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(onionApi: TestApi.self) + + var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + + mockStorage + .read { db in + OpenGroupAPI.capabilitiesAndRoom( + db, + 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()) + } + } + } + + // MARK: - Messages + + context("when sending messages") { + var messageData: OpenGroupAPI.Message! + + beforeEach { + class TestApi: TestOnionRequestAPI { + 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 = TestApi.data + dependencies = dependencies.with(onionApi: TestApi.self) + } + + afterEach { + messageData = nil + } + + it("correctly sends the message") { + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + 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() + + 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: 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")) + } + + context("when unblinded") { + beforeEach { + 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)? + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request body + 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("TestStandardSignature".data(using: .utf8))) + } + + 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)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if there is no user key pair") { + 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)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.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: anyArray(), keyPair: any()) }.thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + + context("when blinded") { + beforeEach { + 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)? + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request body + 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("TestSogsSignature".data(using: .utf8))) + } + + 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)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if there is no ed key pair key") { + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if no signature is generated") { + mockSodium + .when { + $0.sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + } + .thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.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 TestApi: TestOnionRequestAPI { + 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(onionApi: TestApi.self) + + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate the response data + expect(response?.data).to(equal(TestApi.data)) + + // 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")) + } + } + + context("when updating a message") { + beforeEach { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + 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?)? + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + + context("when unblinded") { + beforeEach { + 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?)? + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request body + 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("TestStandardSignature".data(using: .utf8))) + } + + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if there is no user key pair") { + mockStorage.write { db in + _ = try Identity.filter(id: .x25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) + } + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.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: anyArray(), keyPair: any()) }.thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + + context("when blinded") { + beforeEach { + 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?)? + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request body + 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("TestSogsSignature".data(using: .utf8))) + } + + it("fails to sign if there is no open group") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if there is no ed key pair key") { + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails to sign if no signature is generated") { + mockSodium + .when { + $0.sogsSignature( + message: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + } + .thenReturn(nil) + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + } + + context("when deleting a message") { + it("generates the request and handles the response correctly") { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + } + + context("when deleting all messages for a user") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + beforeEach { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + } + + // MARK: - Pinning + + context("when pinning a message") { + it("generates the request and handles the response correctly") { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + var response: OnionRequestResponseInfoType? + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + } + + context("when unpinning a message") { + it("generates the request and handles the response correctly") { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + var response: OnionRequestResponseInfoType? + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + } + + context("when unpinning all messages") { + it("generates the request and handles the response correctly") { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + var response: OnionRequestResponseInfoType? + + mockStorage + .read { db in + OpenGroupAPI + .unpinAll( + db, + 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: 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")) + } + } + + // MARK: - Files + + context("when uploading files") { + it("generates the request and handles the response correctly") { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { + return try! JSONEncoder().encode(FileUploadResponse(id: "1")) + } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + + it("doesn't add a fileName to the content-disposition header when not provided") { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { + return try! JSONEncoder().encode(FileUploadResponse(id: "1")) + } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + 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 TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { + return try! JSONEncoder().encode(FileUploadResponse(id: "1")) + } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate signature headers + 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 TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { + return Data() + } + } + dependencies = dependencies.with(onionApi: TestApi.self) + + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + } + + // MARK: - Inbox/Outbox (Message Requests) + + context("when sending message requests") { + var messageData: OpenGroupAPI.SendDirectMessageResponse! + + beforeEach { + class TestApi: TestOnionRequestAPI { + 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 = TestApi.data + dependencies = dependencies.with(onionApi: TestApi.self) + } + + afterEach { + messageData = nil + } + + it("correctly sends the message request") { + var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? + + 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() + + 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: 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")) + } + } + + // MARK: - Users + + context("when banning a user") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + beforeEach { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + + it("does a global ban if no room tokens are provided") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + 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()) + expect(requestBody.rooms).to(beNil()) + } + + it("does room specific bans if room tokens are provided") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + 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()) + expect(requestBody.rooms).to(equal(["testRoom"])) + } + } + + context("when unbanning a user") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + beforeEach { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + + it("does a global ban if no room tokens are provided") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + 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()) + expect(requestBody.rooms).to(beNil()) + } + + it("does room specific bans if room tokens are provided") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + 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()) + expect(requestBody.rooms).to(equal(["testRoom"])) + } + } + + context("when updating a users permissions") { + var response: (info: OnionRequestResponseInfoType, data: Data?)? + + beforeEach { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { return Data() } + } + dependencies = dependencies.with(onionApi: TestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + + it("does a global update if no room tokens are provided") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + 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()) + expect(requestBody.rooms).to(beNil()) + } + + it("does room specific updates if room tokens are provided") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + 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()) + expect(requestBody.rooms).to(equal(["testRoom"])) + } + + it("fails if neither moderator or admin are set") { + 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() + + expect(error?.localizedDescription) + .toEventually( + equal(HTTP.Error.generic.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + + context("when banning and deleting all messages for a user") { + var response: [OnionRequestResponseInfoType]? + + beforeEach { + class TestApi: TestOnionRequestAPI { + 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: nil, + failedToParseBody: false + ) + ) + ] + + return "[\(responses.map { String(data: $0, encoding: .utf8)! }.joined(separator: ","))]".data(using: .utf8) + } + } + dependencies = dependencies.with(onionApi: TestApi.self) + } + + afterEach { + response = nil + } + + it("generates the request and handles the response correctly") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // 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")) + } + + it("bans the user from the specified room rather than globally") { + 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() + + expect(response) + .toEventuallyNot( + beNil(), + timeout: .milliseconds(100) + ) + expect(error?.localizedDescription).to(beNil()) + + // Validate request data + let requestData: TestOnionRequestAPI.RequestData? = (response?.first as? TestOnionRequestAPI.ResponseInfo)?.requestData + let jsonObject: Any = try! JSONSerialization.jsonObject( + with: requestData!.body!, + options: [.fragmentsAllowed] + ) + 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: firstJsonData) + + expect(firstRequestBody.global).to(beNil()) + expect(firstRequestBody.rooms).to(equal(["testRoom"])) + } + } + + // MARK: - Authentication + + context("when signing") { + beforeEach { + class TestApi: TestOnionRequestAPI { + override class var mockResponse: Data? { + return try! JSONEncoder().encode([OpenGroupAPI.Room]()) + } + } + + dependencies = dependencies.with(onionApi: TestApi.self) + } + + it("fails when there is no userEdKeyPair") { + mockStorage.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails when there is no serverPublicKey") { + mockStorage.write { db in + _ = try OpenGroup.deleteAll(db) + } + + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.noPublicKey.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails when the serverPublicKey is not a hex string") { + mockStorage.write { db in + _ = try OpenGroup.updateAll(db, OpenGroup.Columns.publicKey.set(to: "TestString!!!")) + } + + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + context("when unblinded") { + beforeEach { + mockStorage.write { db in + _ = try Capability.deleteAll(db) + try Capability(openGroupServer: "testserver", variant: .sogs, isMissing: false).insert(db) + } + } + + it("signs correctly") { + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "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: 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")) + expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(requestData?.headers).to(haveCount(4)) + expect(requestData?.headers[Header.sogsPubKey.rawValue]) + .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())) + } + + it("fails when the signature is not generated") { + mockSign.when { $0.signature(message: anyArray(), secretKey: anyArray()) }.thenReturn(nil) + + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + + context("when blinded") { + beforeEach { + 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") { + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "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: 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")) + expect(requestData?.publicKey).to(equal("88672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(requestData?.headers).to(haveCount(4)) + 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())) + } + + it("fails when the blindedKeyPair is not generated") { + mockSodium + .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + .thenReturn(nil) + + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + + it("fails when the sogsSignature is not generated") { + mockSodium + .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } + .thenReturn(nil) + + mockStorage + .read { db in + OpenGroupAPI + .rooms( + db, + server: "testserver", + using: dependencies + ) + } + .get { result in response = result } + .catch { requestError in error = requestError } + .retainUntilComplete() + + expect(error?.localizedDescription) + .toEventually( + equal(OpenGroupAPIError.signingFailed.localizedDescription), + timeout: .milliseconds(100) + ) + + expect(response).to(beNil()) + } + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift new file mode 100644 index 000000000..e14a1099f --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -0,0 +1,3706 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import PromiseKit +import GRDB +import Sodium +import SessionSnodeKit +import SessionUtilitiesKit + +import Quick +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", + roomDescription: nil, + infoUpdates: 10, + 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 mockOGMCache: MockOGMCache! + var mockGeneralCache: MockGeneralCache! + var mockStorage: Storage! + 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 testInteraction1: Interaction! + var testGroupThread: SessionThread! + var testOpenGroup: OpenGroup! + var testPollInfo: OpenGroupAPI.RoomPollInfo! + var testMessage: OpenGroupAPI.Message! + var testDirectMessage: OpenGroupAPI.DirectMessage! + + var cache: OpenGroupManager.Cache! + var openGroupManager: OpenGroupManager! + + describe("an OpenGroupManager") { + // MARK: - Configuration + + beforeEach { + mockOGMCache = MockOGMCache() + mockGeneralCache = MockGeneralCache() + mockStorage = Storage( + customWriter: DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNMessagingKit.migrations() + ] + ) + mockSodium = MockSodium() + mockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() + mockGenericHash = MockGenericHash() + mockSign = MockSign() + mockNonce16Generator = MockNonce16Generator() + mockNonce24Generator = MockNonce24Generator() + mockUserDefaults = MockUserDefaults() + dependencies = OpenGroupManager.OGMDependencies( + cache: Atomic(mockOGMCache), + onionApi: TestCapabilitiesAndRoomApi.self, + generalCache: Atomic(mockGeneralCache), + storage: mockStorage, + sodium: mockSodium, + genericHash: mockGenericHash, + sign: mockSign, + aeadXChaCha20Poly1305Ietf: mockAeadXChaCha20Poly1305Ietf, + ed25519: MockEd25519(), + nonceGenerator16: mockNonce16Generator, + nonceGenerator24: mockNonce24Generator, + standardUserDefaults: mockUserDefaults, + date: Date(timeIntervalSince1970: 1234567890) + ) + 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 = SessionThread( + id: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + variant: .openGroup + ) + testOpenGroup = OpenGroup( + server: "testServer", + roomToken: "testRoom", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 10, + sequenceNumber: 5 + ) + 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 + ) + 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 + ) + 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() + ) + + 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 testGroupThread.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)") + mockGenericHash.when { $0.hash(message: anyArray(), 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: anyArray(), + secretKey: anyArray(), + blindedSecretKey: anyArray(), + blindedPublicKey: anyArray() + ) + } + .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() + } + + 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 + mockGenericHash = nil + mockSign = nil + mockUserDefaults = nil + dependencies = nil + + testInteraction1 = nil + testGroupThread = 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 + + context("when starting polling") { + beforeEach { + mockStorage.write { db in + try OpenGroup( + server: "testServer1", + roomToken: "testRoom1", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test1", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0 + ).insert(db) + } + + 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.startPolling(using: dependencies) + + expect(mockOGMCache) + .to(call(matchingParameters: true) { + $0.pollers = [ + "testserver": OpenGroupAPI.Poller(for: "testserver"), + "testserver1": OpenGroupAPI.Poller(for: "testserver1") + ] + }) + } + + it("updates the isPolling flag") { + openGroupManager.startPolling(using: dependencies) + + expect(mockOGMCache).to(call(matchingParameters: true) { $0.isPolling = true }) + } + + it("does nothing if already polling") { + mockOGMCache.when { $0.isPolling }.thenReturn(true) + + openGroupManager.startPolling(using: dependencies) + + expect(mockOGMCache).toNot(call { $0.pollers }) + } + } + + context("when stopping polling") { + beforeEach { + mockStorage.write { db in + try OpenGroup( + server: "testServer1", + roomToken: "testRoom1", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test1", + roomDescription: nil, + imageId: nil, + imageData: nil, + userCount: 0, + infoUpdates: 0 + ).insert(db) + } + + 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.startPolling(using: dependencies) + } + + it("removes all pollers") { + openGroupManager.stopPolling(using: dependencies) + + expect(mockOGMCache).to(call(matchingParameters: true) { $0.pollers = [:] }) + } + + it("updates the isPolling flag") { + openGroupManager.stopPolling(using: dependencies) + + expect(mockOGMCache).to(call(matchingParameters: true) { $0.isPolling = false }) + } + } + + // 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") { + context("when there is a thread for the room and the cache has a poller") { + 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( + 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( + 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( + 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")]) + } + + it("returns true when no scheme is provided") { + expect( + 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( + 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( + 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")]) + } + + it("returns true when no scheme is provided") { + expect( + 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( + 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( + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "https://testServer", + publicKey: "testKey", + 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")]) + 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( + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "http://open.getsession.org", + publicKey: "testKey", + 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")]) + 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( + 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")]) + + expect( + 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([:]) + + expect( + 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")]) + mockStorage.write { db in + try SessionThread.deleteAll(db) + } + + expect( + mockStorage.read { db in + openGroupManager + .hasExistingOpenGroup( + db, + roomToken: "testRoom", + server: "testServer", + publicKey: "testKey", + dependencies: dependencies + ) + } + ).to(beFalse()) + } + } + + // MARK: - --add + + context("when adding") { + beforeEach { + mockStorage.write { db in + try OpenGroup.deleteAll(db) + } + + mockOGMCache.when { $0.pollers }.thenReturn([:]) + + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(Date(timeIntervalSince1970: 1234567890)) + } + + it("stores the open group server") { + var didComplete: Bool = false // Prevent multi-threading test bugs + + 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 + .read { db in + try OpenGroup + .select(.threadId) + .asRequest(of: String.self) + .fetchOne(db) + } + ) + .to(equal(OpenGroup.idFor(roomToken: "testRoom", server: "testServer"))) + } + + it("adds a poller") { + var didComplete: Bool = false // Prevent multi-threading test bugs + + 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(mockOGMCache) + .toEventually( + call(matchingParameters: true) { + $0.pollers = ["testserver": OpenGroupAPI.Poller(for: "testserver")] + }, + timeout: .milliseconds(50) + ) + } + + context("an existing room") { + beforeEach { + 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 + + 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 + .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)) + } + } + + 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? + + 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( + equal(HTTP.Error.parsingFailed.localizedDescription), + timeout: .milliseconds(50) + ) + } + } + } + + // MARK: - --delete + + context("when deleting") { + beforeEach { + 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 all interactions for the thread") { + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }) + .to(equal(0)) + } + + it("removes the given thread") { + 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")]) + + 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") { + 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(0)) + } + } + + context("and the are multiple open groups for this server") { + beforeEach { + 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 remove the open group") { + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer), + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }) + .to(equal(2)) + } + + it("deactivates the open group") { + mockStorage.write { db in + openGroupManager + .delete( + db, + openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer), + dependencies: dependencies + ) + } + + 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()) + } + } + } + + // MARK: - Response Processing + + // MARK: - --handleCapabilities + + context("when handling capabilities") { + beforeEach { + mockStorage.write { db in + OpenGroupManager + .handleCapabilities( + db, + capabilities: OpenGroupAPI.Capabilities(capabilities: [.sogs], missing: []), + on: "testserver" + ) + } + } + + it("stores the capabilities") { + expect(mockStorage.read { db in try Capability.fetchCount(db) }) + .to(equal(1)) + } + } + + // MARK: - --handlePollInfo + + context("when handling room poll info") { + beforeEach { + mockStorage.write { db in + try OpenGroup.deleteAll(db) + + try testOpenGroup.insert(db) + } + + mockOGMCache.when { $0.pollers }.thenReturn([:]) + + mockUserDefaults + .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .thenReturn(nil) + } + + it("saves the updated open group") { + var didComplete: Bool = false // Prevent multi-threading test bugs + + 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( + 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 + + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) { 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 + + 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( + beTrue(), + timeout: .milliseconds(50) + ) + } + + it("calls the room image completion block when waiting and there is an image") { + var didCallComplete: Bool = false + + 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) + } + + 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( + beTrue(), + timeout: .milliseconds(50) + ) + } + + context("and updating the moderator list") { + it("successfully updates") { + var didComplete: Bool = false // Prevent multi-threading test bugs + + 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: []) + ) + + 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( + 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("does not insert mods if no moderators are provided") { + var didComplete: Bool = false // Prevent multi-threading test bugs + + 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 + ) + + 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(mockStorage.read { db in try GroupMember.fetchCount(db) }) + .to(equal(0)) + } + } + + context("and updating the admin list") { + it("successfully updates") { + var didComplete: Bool = false // Prevent multi-threading test bugs + + 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"]) + ) + + 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( + 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("does not insert an admin if no admins are provided") { + var didComplete: Bool = false // Prevent multi-threading test bugs + + 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 + ) + + 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(mockStorage.read { db in try GroupMember.fetchCount(db) }) + .to(equal(0)) + } + } + + context("when it cannot get the open group") { + it("does not save the thread") { + mockStorage.write { db in + try OpenGroup.deleteAll(db) + } + + mockStorage.write { db in + try OpenGroupManager.handlePollInfo( + db, + pollInfo: testPollInfo, + publicKey: TestConstants.publicKey, + for: "testRoom", + on: "testServer", + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }).to(equal(0)) + } + } + + 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 + + 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.read { db in + try OpenGroup + .select(.publicKey) + .asRequest(of: String.self) + .fetchOne(db) + } + ).to(equal(TestConstants.publicKey)) + } + } + + 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([:]) + + 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")] + }) + } + + 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")]) + + 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 }) + } + } + + 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.write { db in + try OpenGroup + .updateAll(db, OpenGroup.Columns.imageData.set(to: nil)) + } + + mockOGMCache.when { $0.groupImagePromises } + .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Promise.value(imageData)]) + } + + 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, + 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 + ) + ) + + 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.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.write { db in + try OpenGroup.deleteAll(db) + try OpenGroup( + server: "testServer", + roomToken: "testRoom", + publicKey: TestConstants.publicKey, + isActive: true, + name: "Test", + imageId: "12", + imageData: Data([1, 2, 3]), + userCount: 0, + infoUpdates: 10 + ).insert(db) + } + + 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 + ) + + 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.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 + + 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, + 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 + ) + ) + + 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.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) + ) + } + + it("does nothing if there is no room image") { + var didComplete: Bool = false // Prevent multi-threading test bugs + + 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.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([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): 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 + ) + ) + + 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.read { db in + try OpenGroup + .select(.imageData) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).to(beNil()) + } + + it("saves the retrieved room image") { + var didComplete: Bool = false // Prevent multi-threading test bugs + + 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 + ) + ) + + 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.read { db in + try OpenGroup + .select(.imageData) + .asRequest(of: Data.self) + .fetchOne(db) + } + ).toNot(beNil()) + } + } + } + + // MARK: - --handleMessages + + context("when handling messages") { + beforeEach { + 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") { + 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.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") { + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + 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") { + mockStorage.write { db in + try Interaction.deleteAll(db) + } + + 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") { + mockStorage.write { db in + try Interaction.deleteAll(db) + } + + 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") { + mockStorage.write { db in + OpenGroupManager.handleMessages( + db, + messages: [testMessage], + for: "testRoom", + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) + } + + it("processes valid messages when combined with invalid ones") { + 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.write { db in + try Interaction + .updateAll( + db, + Interaction.Columns.openGroupServerMessageId.set(to: 127) + ) + } + + 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 + ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) + } + + it("does nothing if we do not have the message") { + 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 + ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) + } + } + } + + // MARK: - --handleDirectMessages + + context("when handling direct messages") { + beforeEach { + mockSodium + .when { + $0.sharedBlindedEncryptionKey( + secretKey: anyArray(), + otherBlindedPublicKey: anyArray(), + fromBlindedPublicKey: anyArray(), + toBlindedPublicKey: anyArray(), + genericHash: mockGenericHash + ) + } + .thenReturn([]) + mockSodium + .when { $0.generateBlindingFactor(serverPublicKey: any(), genericHash: mockGenericHash) } + .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) + } + + it("does nothing if there are no messages") { + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + 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") { + mockStorage.write { db in + try OpenGroup.deleteAll(db) + } + + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + 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.replacingOccurrences(of: "8", with: "9"), + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: "TestMessage%%%" + ) + + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).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") { + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + 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.replacingOccurrences(of: "8", with: "9"), + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() + ) + + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + 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") { + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: false, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) + } + + it("processes valid messages when combined with invalid ones") { + 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 + ) + } + + expect(mockStorage.read { db in try Interaction.fetchCount(db) }).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") { + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + expect( + mockStorage.read { db in + try OpenGroup + .select(.outboxLatestMessageId) + .asRequest(of: Int64.self) + .fetchOne(db) + } + ).to(equal(128)) + } + + 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 + ) + } + + 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 lookup is found") { + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + 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.replacingOccurrences(of: "8", with: "9"), + recipient: testDirectMessage.recipient, + posted: testDirectMessage.posted, + expires: testDirectMessage.expires, + base64EncodedMessage: Data([1, 2, 3]).base64EncodedString() + ) + + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(1)) + } + + it("processes a message with valid data") { + mockStorage.write { db in + OpenGroupManager.handleDirectMessages( + db, + messages: [testDirectMessage], + fromOutbox: true, + on: "testServer", + isBackgroundPoll: false, + dependencies: dependencies + ) + } + + expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(2)) + } + + it("processes valid messages when combined with invalid ones") { + 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 + ) + } + + expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(2)) + } + } + } + + // MARK: - Convenience + + // MARK: - --isUserModeratorOrAdmin + + context("when determining if a user is a moderator or an admin") { + beforeEach { + mockStorage.write { db in + _ = try GroupMember.deleteAll(db) + } + } + + 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") { + mockStorage.write { db in + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "05\(TestConstants.publicKey)", + role: .moderator + ).insert(db) + } + + 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") { + mockStorage.write { db in + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "05\(TestConstants.publicKey)", + role: .admin + ).insert(db) + } + + 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") { + 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( + "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") { + 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( + "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") + 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 + ) + ) + mockStorage.write { db in + try GroupMember( + groupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), + profileId: "15\(otherKey)", + role: .moderator + ).insert(db) + } + + 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.write { db in + try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + 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") { + 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( + "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") + mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") + 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( + "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") + 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 + ) + ) + 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( + "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.write { db in + try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + 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") + mockGeneralCache.when { $0.encodedPublicKey }.thenReturn("05\(otherKey)") + 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 + ) + ) + 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( + "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") { + 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 + ) + ) + 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( + "15\(TestConstants.publicKey)", + for: "testRoom", + on: "testServer", + using: dependencies + ) + ).to(beTrue()) + } + } + } + + // 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) + + 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([:]) + 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") { + 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 open group information") { + OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) + + 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("https://open.getsession.org")) + 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") { + 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(50) + ) + } + + 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(50) + ) + 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(50) + ) + 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) + } + } + let testDate: Date = Date(timeIntervalSince1970: 1234567890) + dependencies = dependencies.with( + onionApi: TestRoomsApi.self, + date: testDate + ) + + OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) + + expect(mockUserDefaults) + .toEventually( + call(matchingParameters: true) { + $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!)) + } + } + + // 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(()) + 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([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): promise]) + + let promise2 = mockStorage.read { db in + OpenGroupManager + .roomImage( + db, + fileId: "1", + for: "testRoom", + on: "testServer", + using: dependencies + ) + } + expect(promise2).to(equal(promise)) + } + + it("does not save the fetched image to storage") { + 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.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 = 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(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: OnionRequestAPIVersion, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + return Promise<(OnionRequestResponseInfoType, Data?)>.pending().promise + } + + static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise { + return Promise.value(Data()) + } + } + dependencies = dependencies.with(onionApi: TestNeverReturningApi.self) + + 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 = [OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): 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 = mockStorage + .read { db in + OpenGroupManager + .roomImage( + db, + fileId: "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 = 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.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 = 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(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 { + dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567890)) + mockUserDefaults.when { $0.object(forKey: any()) }.thenReturn(dependencies.date) + 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 = mockStorage + .read { db in + OpenGroupManager + .roomImage( + db, + fileId: "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 = mockStorage + .read { db in + OpenGroupManager + .roomImage( + db, + fileId: "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")) + } + } + } + } +} + +// 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/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..1dfb972d9 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Types/SOGSEndpointSpec.swift @@ -0,0 +1,68 @@ +// 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")) + expect(OpenGroupAPI.Endpoint.roomDeleteMessages("test", sessionId: "testId").path) + .to(equal("room/test/all/testId")) + + // 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")) + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift b/SessionMessagingKitTests/Open Groups/Types/SOGSErrorSpec.swift new file mode 100644 index 000000000..cba429cee --- /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(OpenGroupAPIError.decryptionFailed.errorDescription) + .to(equal("Couldn't decrypt response.")) + expect(OpenGroupAPIError.signingFailed.errorDescription) + .to(equal("Couldn't sign message.")) + expect(OpenGroupAPIError.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..f27eee889 --- /dev/null +++ b/SessionMessagingKitTests/Open Groups/Types/SodiumProtocolsSpec.swift @@ -0,0 +1,98 @@ +// 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 mockAead: MockAeadXChaCha20Poly1305Ietf = MockAeadXChaCha20Poly1305Ietf() + mockAead + .when { + $0.encrypt( + message: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + additionalData: anyArray() + ) + } + .thenReturn(testValue) + mockAead + .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: anyArray(), secretKey: anyArray(), nonce: anyArray(), additionalData: anyArray()) + }) + + expect(mockAead) + .to(call { + $0.decrypt( + authenticatedCipherText: anyArray(), + secretKey: anyArray(), + nonce: anyArray(), + additionalData: anyArray() + ) + }) + } + } + + describe("a GenericHashType") { + let testValue: [UInt8] = [1, 2, 3] + + it("provides the default values in it's extensions") { + let mockGenericHash: MockGenericHash = MockGenericHash() + mockGenericHash + .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: anyArray(), key: anyArray()) }) + expect(mockGenericHash) + .to(call { + $0.hashSaltPersonal( + message: anyArray(), + outputLength: any(), + key: anyArray(), + salt: anyArray(), + personal: anyArray() + ) + }) + } + } + } +} diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift new file mode 100644 index 000000000..760290e27 --- /dev/null +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift @@ -0,0 +1,504 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import GRDB +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class MessageReceiverDecryptionSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var mockStorage: Storage! + var mockSodium: MockSodium! + var mockBox: MockBox! + var mockGenericHash: MockGenericHash! + var mockSign: MockSign! + var mockAeadXChaCha: MockAeadXChaCha20Poly1305Ietf! + var mockNonce24Generator: MockNonce24Generator! + var dependencies: SMKDependencies! + + describe("a MessageReceiver") { + beforeEach { + mockStorage = Storage( + customWriter: DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNMessagingKit.migrations() + ] + ) + 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 = SMKDependencies( + storage: mockStorage, + sodium: mockSodium, + box: mockBox, + genericHash: mockGenericHash, + sign: mockSign, + aeadXChaCha20Poly1305Ietf: mockAeadXChaCha, + nonceGenerator24: mockNonce24Generator + ) + + 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( + 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: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes + ), + dependencies: SMKDependencies() + ) + + 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: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes + ), + dependencies: dependencies + ) + } + .to(throwError(MessageReceiverError.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: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes + ), + dependencies: dependencies + ) + } + .to(throwError(MessageReceiverError.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: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes + ), + dependencies: dependencies + ) + } + .to(throwError(MessageReceiverError.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: Box.KeyPair( + publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, + secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes + ), + dependencies: dependencies + ) + } + .to(throwError(MessageReceiverError.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: SMKDependencies() + ) + + 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(MessageReceiverError.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(MessageReceiverError.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(MessageReceiverError.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(MessageReceiverError.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(MessageReceiverError.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(MessageReceiverError.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(MessageReceiverError.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(MessageReceiverError.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(MessageReceiverError.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(MessageReceiverError.decryptionFailed)) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift new file mode 100644 index 000000000..2016fa2a0 --- /dev/null +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift @@ -0,0 +1,283 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class MessageSenderEncryptionSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + var mockStorage: Storage! + var mockBox: MockBox! + var mockSign: MockSign! + var mockNonce24Generator: MockNonce24Generator! + var dependencies: SMKDependencies! + + describe("a MessageSender") { + beforeEach { + mockStorage = Storage( + customWriter: DatabaseQueue(), + customMigrations: [ + SNUtilitiesKit.migrations(), + SNMessagingKit.migrations() + ] + ) + mockBox = MockBox() + mockSign = MockSign() + mockNonce24Generator = MockNonce24Generator() + + dependencies = SMKDependencies( + storage: mockStorage, + box: mockBox, + sign: mockSign, + nonceGenerator24: mockNonce24Generator + ) + + 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) + } + + 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: SMKDependencies(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.write { db in + _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) + _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) + } + + expect { + try MessageSender.encryptWithSessionProtocol( + "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: dependencies + ) + } + .to(throwError(MessageSenderError.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(MessageSenderError.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(MessageSenderError.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))..? = nil, + storage: Storage? = nil, + sodium: SodiumType? = nil, + box: BoxType? = nil, + genericHash: GenericHashType? = nil, + sign: SignType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = nil, + ed25519: Ed25519Type? = nil, + nonceGenerator16: NonceGenerator16ByteType? = nil, + nonceGenerator24: NonceGenerator24ByteType? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) -> SMKDependencies { + return SMKDependencies( + onionApi: (onionApi ?? self._onionApi), + generalCache: (generalCache ?? self._generalCache), + storage: (storage ?? self._storage), + sodium: (sodium ?? self._sodium), + 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), + standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults), + date: (date ?? self._date) + ) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift new file mode 100644 index 000000000..09b0f9ce1 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift @@ -0,0 +1,20 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class MockAeadXChaCha20Poly1305Ietf: Mock, AeadXChaCha20Poly1305IetfType { + var KeyBytes: Int = 32 + var ABytes: Int = 16 + + func encrypt(message: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { + return accept(args: [message, secretKey, nonce, additionalData]) as? Bytes + } + + func decrypt(authenticatedCipherText: Bytes, secretKey: Bytes, nonce: Bytes, additionalData: Bytes?) -> Bytes? { + return accept(args: [authenticatedCipherText, secretKey, nonce, additionalData]) as? Bytes + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockBox.swift b/SessionMessagingKitTests/_TestUtilities/MockBox.swift new file mode 100644 index 000000000..ff8756977 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockBox.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class MockBox: Mock, 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/MockEd25519.swift b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift new file mode 100644 index 000000000..d92250663 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift @@ -0,0 +1,17 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class MockEd25519: Mock, Ed25519Type { + func sign(data: Bytes, keyPair: Box.KeyPair) 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/MockGeneralCache.swift b/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift new file mode 100644 index 000000000..c47b8d6eb --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class MockGeneralCache: Mock, GeneralCacheType { + var encodedPublicKey: String? { + get { return accept() as? String } + set { accept(args: [newValue]) } + } +} 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/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/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift new file mode 100644 index 000000000..31bace48f --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift @@ -0,0 +1,43 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import SessionUtilitiesKit + +@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 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/MockSign.swift b/SessionMessagingKitTests/_TestUtilities/MockSign.swift new file mode 100644 index 000000000..98f2887db --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockSign.swift @@ -0,0 +1,24 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class MockSign: Mock, SignType { + var Bytes: Int = 64 + var PublicKeyBytes: Int = 32 + + func signature(message: Bytes, secretKey: Bytes) -> Bytes? { + return accept(args: [message, secretKey]) as? Bytes + } + + func verify(message: Bytes, publicKey: Bytes, signature: Bytes) -> Bool { + return accept(args: [message, publicKey, signature]) as! Bool + } + + func toX25519(ed25519PublicKey: Bytes) -> 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..4ed5bb75f --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockSodium.swift @@ -0,0 +1,38 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import PromiseKit +import Sodium + +@testable import SessionMessagingKit + +class MockSodium: Mock, SodiumType { + func getBox() -> BoxType { return accept() as! BoxType } + func getGenericHash() -> GenericHashType { return accept() as! GenericHashType } + func getSign() -> SignType { return accept() as! SignType } + func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return accept() as! AeadXChaCha20Poly1305IetfType } + + 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? { + 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, genericHash: GenericHashType) -> Bool { + return accept(args: [sessionId, blindedSessionId, serverPublicKey, genericHash]) as! Bool + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift b/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift new file mode 100644 index 000000000..dcf3f41b4 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockUserDefaults.swift @@ -0,0 +1,25 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +class MockUserDefaults: Mock, UserDefaultsType { + 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 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/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift new file mode 100644 index 000000000..d559bdfec --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -0,0 +1,43 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +extension OpenGroupManager.OGMDependencies { + public func with( + cache: Atomic? = nil, + onionApi: OnionRequestAPIType.Type? = nil, + generalCache: Atomic? = nil, + storage: Storage? = nil, + sodium: SodiumType? = nil, + box: BoxType? = nil, + genericHash: GenericHashType? = nil, + sign: SignType? = nil, + aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType? = 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), + generalCache: (generalCache ?? self._generalCache), + storage: (storage ?? self._storage), + sodium: (sodium ?? self._sodium), + 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), + standardUserDefaults: (standardUserDefaults ?? self._standardUserDefaults), + date: (date ?? self._date) + ) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift new file mode 100644 index 000000000..67b7dde86 --- /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 +import SessionUtilitiesKit + +@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: OnionRequestAPIVersion + 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: OnionRequestAPIVersion, 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: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise { + return Promise.value(mockResponse!) + } +} diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index c19184a5d..04503ed5f 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -1,72 +1,62 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import SignalUtilitiesKit +import Foundation +import GRDB import UserNotifications +import SignalUtilitiesKit +import SessionMessagingKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { - private var notifications: [String: UNNotificationRequest] = [:] - - 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) { + 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.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 - } - - let senderPublicKey = incomingMessage.authorId - let userPublicKey = GeneralUtilities.getUserPublicKey() - 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 identifier = incomingMessage.notificationIdentifier ?? UUID().uuidString - let isBackgroudPoll = identifier == threadID + let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant) + var notificationTitle: String = senderName - let context = Contact.context(for: thread) - let senderName = Storage.shared.getContact(with: senderPublicKey, using: transaction)?.displayName(for: context) ?? senderPublicKey - - var notificationTitle = senderName - if let group = thread as? TSGroupThread { - if group.isOnlyNotifyingForMentions && !incomingMessage.isUserMentioned { + if thread.variant == .closedGroup || thread.variant == .openGroup { + if thread.onlyNotifyForMentions && !interaction.hasMention { // Ignore PNs if the group is set to only notify for mentions return } - var groupName = thread.name(with: transaction) - if groupName.count < 1 { - groupName = MessageStrings.newGroupDefaultTitle - } - notificationTitle = isBackgroudPoll ? groupName : String(format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, groupName) + notificationTitle = { + let groupName: String = SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: (try? thread.closedGroup.fetchOne(db))?.name, + openGroupName: (try? thread.openGroup.fetchOne(db))?.name + ) + + guard !isBackgroundPoll else { return groupName } + + return String( + format: NotificationStrings.incomingGroupMessageTitleFormat, + senderName, + groupName + ) + }() } - let snippet = incomingMessage.previewText(with: transaction).filterForDisplay?.replacingMentions(for: threadID, using: transaction) - ?? "APN_Message".localized() + let snippet: String = (interaction.previewText(db) + .filterForDisplay? + .replacingMentions(for: thread.id)) + .defaulting(to: "APN_Message".localized()) - var userInfo: [String:Any] = [ NotificationServiceExtension.isFromRemoteKey : true ] - userInfo[NotificationServiceExtension.threadIdKey] = threadID + 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 = thread.notificationSound + .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) + .notificationSound(isQuiet: false) // Badge Number let newBadgeNumber = CurrentAppContext().appUserDefaults().integer(forKey: "currentBadgeNumber") + 1 @@ -74,50 +64,63 @@ 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 // 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 trigger: UNNotificationTrigger? - if isBackgroudPoll { + let identifier = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll) + var trigger: UNNotificationTrigger? + + if isBackgroundPoll { trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false) - let numberOfNotifications: Int - if let lastRequest = notifications[identifier], let counter = lastRequest.content.userInfo[NotificationServiceExtension.threadNotificationCounter] as? Int { - numberOfNotifications = counter + 1 - notificationContent.body = String(format: NotificationStrings.incomingCollapsedMessagesBody, "\(numberOfNotifications)") - } else { - numberOfNotifications = 1 + + var numberOfNotifications: Int = (notifications[identifier]? + .content + .userInfo[NotificationServiceExtension.threadNotificationCounter] + .asType(Int.self)) + .defaulting(to: 1) + + if numberOfNotifications > 1 { + numberOfNotifications += 1 // Add one for the current notification + notificationContent.body = String( + format: NotificationStrings.incomingCollapsedMessagesBody, + "\(numberOfNotifications)" + ) } + notificationContent.userInfo[NotificationServiceExtension.threadNotificationCounter] = numberOfNotifications - } else { - trigger = nil } - + let request = UNNotificationRequest(identifier: identifier, content: notificationContent, trigger: trigger) + SNLog("Add remote notification request: \(notificationContent.body)") 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)") } + self.notifications[identifier] = request semaphore.signal() } @@ -125,33 +128,58 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { SNLog("Finish adding remote notification request") } - public func notifyUser(forIncomingCall callInfoMessage: TSInfoMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) { - guard !thread.isMuted else { return } - guard !thread.isGroupThread() else { return } // Calls shouldn't happen in groups - guard let threadID = thread.uniqueId else { return } - guard [ .missed, .permissionDenied ].contains(callInfoMessage.callState) else { return } // Only notify missed call + public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { + // No call notifications for muted or group threads + guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } + guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + interaction.variant == .infoCall, + let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), + let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( + CallMessage.MessageInfo.self, + from: infoMessageData + ) + else { return } - var userInfo: [String:Any] = [ NotificationServiceExtension.isFromRemoteKey : true ] - userInfo[NotificationServiceExtension.threadIdKey] = threadID + // Only notify missed calls + guard messageInfo.state == .missed || messageInfo.state == .permissionDenied else { return } + + 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 = thread.notificationSound + .defaulting( + to: db[.defaultNotificationSound] + .defaulting(to: Preferences.Sound.defaultNotificationSound) + ) + .notificationSound(isQuiet: false) // Badge Number let newBadgeNumber = CurrentAppContext().appUserDefaults().integer(forKey: "currentBadgeNumber") + 1 notificationContent.badge = NSNumber(value: newBadgeNumber) CurrentAppContext().appUserDefaults().set(newBadgeNumber, forKey: "currentBadgeNumber") - notificationContent.title = callInfoMessage.previewText(with: transaction) + notificationContent.title = interaction.previewText(db) notificationContent.body = "" - if callInfoMessage.callState == .permissionDenied { - notificationContent.body = String(format: "modal_call_missed_tips_explanation".localized(), thread.name(with: transaction)) + + if messageInfo.state == .permissionDenied { + notificationContent.body = String( + format: "modal_call_missed_tips_explanation".localized(), + SessionThread.displayName( + threadId: thread.id, + variant: thread.variant, + closedGroupName: nil, // Not supported + openGroupName: nil // Not supported + ) + ) } // Add request let identifier = UUID().uuidString 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 @@ -164,10 +192,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() { @@ -179,7 +207,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)] = [] @@ -187,8 +215,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(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 d1d598191..df3fed80c 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)? @@ -20,106 +24,138 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension override public func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.contentHandler = contentHandler self.request = request - guard let notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent else { return self.completeSilenty() } + + guard let notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent else { + return self.completeSilenty() + } // Abort if the main app is running - var isMainAppAndActive = false - var isCallOngoing = false - if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") { - isMainAppAndActive = sharedUserDefaults.bool(forKey: "isMainAppActive") - isCallOngoing = sharedUserDefaults.bool(forKey: "isCallOngoing") + guard !(UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { + return self.completeSilenty() } - guard !isMainAppAndActive else { return self.completeSilenty() } + + let isCallOngoing: Bool = (UserDefaults.sharedLokiProject?[.isCallOngoing]) + .defaulting(to: false) // Perform main setup DispatchQueue.main.sync { self.setUpIfNecessary() { } } // 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() } } - 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) } - // 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. - Storage.writeSync { transaction in // Intentionally capture self + // is added to notification center + Storage.shared.write { db in 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() } - } - semaphore.wait() - } - - case let unsendRequest as UnsendRequest: - MessageReceiver.handleUnsendRequest(unsendRequest, using: transaction) - case let closedGroupControlMessage as ClosedGroupControlMessage: - MessageReceiver.handleClosedGroupControlMessage(closedGroupControlMessage, using: transaction) - case let callMessage as CallMessage: - MessageReceiver.handleCallMessage(callMessage, using: transaction) - 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) - } - 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) - } - break - } - self.handleSuccessForIncomingCall(for: callMessage, using: transaction) - default: break + guard let processedMessage: ProcessedMessage = try Message.processRawReceivedMessageAsNotification(db, envelope: envelope) else { + self.handleFailure(for: notificationContent) + return } - } catch { - if let error = error as? MessageReceiver.Error, error.isRetryable { + + 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: (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), + 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: + try MessageReceiver.handleUnsendRequest(db, message: unsendRequest) + + case let closedGroupControlMessage as ClosedGroupControlMessage: + try MessageReceiver.handleClosedGroupControlMessage(db, closedGroupControlMessage) + + case let callMessage as CallMessage: + try MessageReceiver.handleCallMessage(db, message: callMessage) + + guard case .preOffer = callMessage.kind else { return self.completeSilenty() } + + 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 { + try MessageReceiver.handleIncomingCallOfferInBusyState(db, message: callMessage) + break + } + + self.handleSuccessForIncomingCall(db, for: callMessage) + + 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 { switch error { - case .invalidGroupPublicKey, .noGroupKeyPair: self.completeSilenty() - default: self.handleFailure(for: notificationContent) + case .invalidGroupPublicKey, .noGroupKeyPair: self.completeSilenty() + default: self.handleFailure(for: notificationContent) } } } } } } - - 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 @@ -145,19 +181,19 @@ 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 Storage.shared[.isReadyForAppExtensions] else { return completeSilenty() } AppSetup.setupEnvironment( - appSpecificSingletonBlock: { - SSKEnvironment.shared.notificationsManager = NSENotificationPresenter() + appSpecificBlock: { + Environment.shared?.notificationsManager.mutate { + $0 = NSENotificationPresenter() + } }, - migrationCompletion: { [weak self] _, needsConfigSync in + migrationsCompletion: { [weak self] _, needsConfigSync in self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) completion() } ) - - NotificationCenter.default.addObserver(self, selector: #selector(storageIsReady), name: .StorageIsReady, object: nil) } @objc @@ -168,19 +204,14 @@ public final class NotificationServiceExtension : UNNotificationServiceExtension // If we need a config sync then trigger it now if needsConfigSync { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() + Storage.shared.write { db in + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } } checkIsAppReady() } - @objc - private func storageIsReady() { - AssertIsOnMainThread() - - checkIsAppReady() - } - @objc private func checkIsAppReady() { AssertIsOnMainThread() @@ -189,7 +220,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 Storage.shared.isValid && areVersionMigrationsComplete else { return } SignalUtilitiesKit.Configuration.performMainSetup() @@ -210,26 +241,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" @@ -239,15 +277,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)") @@ -270,16 +311,31 @@ 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.getAllV2OpenGroups().values.map { $0.server }) - servers.forEach { server in - let poller = OpenGroupPollerV2(for: server) - let promise = poller.poll(isBackgroundPoll: true).timeout(seconds: 20, timeoutError: NotificationServiceError.timeout) - promises.append(promise) - } + 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 + try OpenGroup + .select(.server) + .filter(OpenGroup.Columns.roomToken != "") + .filter(OpenGroup.Columns.isActive) + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + } + .defaulting(to: []) + .map { server in + OpenGroupAPI.Poller(for: server) + .poll(isBackgroundPoll: true, isPostCapabilitiesRetry: false) + .timeout( + seconds: 20, + timeoutError: NotificationServiceError.timeout + ) + } + return promises } 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/Meta/SignalShareExtension-Bridging-Header.h b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h index 29c93b1b9..dd11e0e98 100644 --- a/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h +++ b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h @@ -9,14 +9,10 @@ #import #import #import -#import -#import #import #import #import -#import #import #import #import #import -#import 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 d72c40a90..d33cf0045 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -43,12 +43,12 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD } AppSetup.setupEnvironment( - appSpecificSingletonBlock: { - SSKEnvironment.shared.notificationsManager = NoopNotificationsManager() + 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) @@ -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), @@ -81,28 +74,21 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD // If we need a config sync then trigger it now if needsConfigSync { - MessageSender.syncConfiguration(forceSyncNow: true).retainUntilComplete() + Storage.shared.write { db in + try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + } } 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 Storage.shared.isValid else { return } guard !AppReadiness.isAppReady() else { // Only mark the app as ready once. return @@ -127,8 +113,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() { @@ -223,6 +207,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) @@ -284,12 +275,8 @@ final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerD private class func createDataSource(utiType: String, url: URL, customFileName: String?) -> 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 1c7896c54..9bea5687a 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -1,9 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + 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?) { @@ -77,48 +78,26 @@ 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?.threadRecord else { return } - - accentLineView.alpha = (thread.isBlocked() ? 1 : 0) - profilePictureView.update(for: thread) - displayNameLabel.text = getDisplayName() - } - - private func getDisplayName() -> String { - if threadViewModel.isGroupThread { - if threadViewModel.name.isEmpty { - // TODO: Localization - return "Unknown Group" - } - - return threadViewModel.name - } - - if threadViewModel.threadRecord.isNoteToSelf() { - return "NOTE_TO_SELF".localized() - } - - guard let hexEncodedPublicKey: String = threadViewModel.contactSessionID else { - // TODO: Localization - return "Unknown" - } - - return ( - Storage.shared.getContact(with: hexEncodedPublicKey)?.displayName(for: .regular) ?? - hexEncodedPublicKey + public func update(with cellViewModel: SessionThreadViewModel) { + accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0) + 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), + showMultiAvatarForClosedGroup: true ) + displayNameLabel.text = cellViewModel.displayName } } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 91a193a7e..b75214326 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -1,24 +1,26 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit -import SignalUtilitiesKit +import GRDB +import PromiseKit +import DifferenceKit +import Sodium 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 +65,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 +74,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 +139,75 @@ 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 = Storage.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: [SessionThreadViewModel]) { + // Ensure the first load runs without animations (if we don't do this the cells will animate + // in from a frame of CGRect.zero) + guard hasLoadedInitialData else { + hasLoadedInitialData = true + UIView.performWithoutAnimation { handleUpdates(updatedViewData) } + return + } + + // Reload the table content (animate changes after the first load) + tableView.reload( + using: StagedChangeset(source: viewModel.viewData, target: updatedViewData), + with: .automatic, + interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues + ) { [weak self] updatedData in + self?.viewModel.updateData(updatedData) + } + } + + // MARK: - UITableViewDataSource + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Int(threadCount) + return self.viewModel.viewData.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[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() - } - // 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[indexPath.row].threadId, + 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 +216,54 @@ 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!) + Storage.shared + .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) + return Promise(error: MessageSenderError.noThread) + } + + // Create the interaction + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: body, + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: body), + 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: LinkPreviewDraft = 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() @@ -214,33 +280,11 @@ 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 + func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { } - 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 - } + func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { } } diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift new file mode 100644 index 000000000..93035647f --- /dev/null +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -0,0 +1,38 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SignalUtilitiesKit +import SessionMessagingKit + +public class ThreadPickerViewModel { + /// This value is the current state of the view + public private(set) var viewData: [SessionThreadViewModel] = [] + + /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise + /// performance https://github.com/groue/GRDB.swift#valueobservation-performance + /// + /// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static + /// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries + /// + /// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`) + /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own + /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) + /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this + public lazy var observableViewData = ValueObservation + .trackingConstantRegion { db -> [SessionThreadViewModel] in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return try SessionThreadViewModel + .shareQuery(userPublicKey: userPublicKey) + .fetchAll(db) + } + .removeDuplicates() + + // MARK: - Functions + + public func updateData(_ updatedData: [SessionThreadViewModel]) { + self.viewData = updatedData + } +} diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index b880ae6e1..11a6c6944 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -1,13 +1,26 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -public struct SNSnodeKitConfiguration { - public let storage: SessionSnodeKitStorageProtocol - - internal static var shared: SNSnodeKitConfiguration! -} +import Foundation +import SessionUtilitiesKit public enum SNSnodeKit { // Just to make the external API nice + public static func migrations() -> TargetMigrations { + return TargetMigrations( + identifier: .snodeKit, + migrations: [ + [ + _001_InitialSetupMigration.self, + _002_SetupStandardJobs.self + ], + [ + _003_YDBToGRDBMigration.self + ] + ] + ) + } - public static func configure(storage: SessionSnodeKitStorageProtocol) { - SNSnodeKitConfiguration.shared = SNSnodeKitConfiguration(storage: storage) + public static func configure() { + // Configure the job executors + JobRunner.add(executor: GetSnodePoolJob.self, for: .getSnodePool) } } diff --git a/SessionSnodeKit/Database/LegacyDatabase/SSKLegacy.swift b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacy.swift new file mode 100644 index 000000000..375634444 --- /dev/null +++ b/SessionSnodeKit/Database/LegacyDatabase/SSKLegacy.swift @@ -0,0 +1,66 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum SSKLegacy { + // 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" + internal static let receivedMessagesCollection = "LokiReceivedMessagesCollection" + + // MARK: - Types + + public typealias LegacyOnionRequestAPIPath = [Snode] + + @objc(Snode) + public final class Snode: NSObject, NSCoding { + public let address: String + public let port: UInt16 + public let publicKeySet: KeySet + + // MARK: - Nested Types + + public struct KeySet { + public let ed25519Key: String + public let x25519Key: String + } + + // MARK: - NSCoding + + 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) { + fatalError("encode(with:) should never be called for legacy types") + } + + // Note: The 'isEqual' and 'hash' overrides are both needed to ensure the migration + // doesn't try to insert duplicate SNode entries into the new database (which would + // result in unique key constraint violations) + override public func isEqual(_ other: Any?) -> Bool { + guard let other = other as? Snode else { return false } + + return address == other.address && port == other.port + } + + override public var hash: Int { + 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..dc023922c --- /dev/null +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -0,0 +1,57 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +enum _001_InitialSetupMigration: Migration { + static let target: TargetMigrations.Identifier = .snodeKit + static let identifier: String = "initialSetup" + 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 + 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 // Delete if Snode deleted + ) + t.primaryKey([.key, .nodeIndex]) + } + + try db.create(table: SnodeReceivedMessageInfo.self) { t in + t.column(.id, .integer) + .notNull() + .primaryKey(autoincrement: true) + t.column(.key, .text) + .notNull() + .indexed() // Quicker querying + t.column(.hash, .text) + .notNull() + .indexed() // Quicker querying + t.column(.expirationDateMs, .integer) + .notNull() + .indexed() // Quicker querying + + t.uniqueKey([.key, .hash]) + } + + 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 new file mode 100644 index 000000000..89a825f56 --- /dev/null +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -0,0 +1,34 @@ +// 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 target: TargetMigrations.Identifier = .snodeKit + static let identifier: String = "SetupStandardJobs" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 + + 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, + shouldSkipLaunchBecomeActive: true + ).inserted(db) + } + + 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 new file mode 100644 index 000000000..54f3421d7 --- /dev/null +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -0,0 +1,214 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import YapDatabase +import SessionUtilitiesKit + +enum _003_YDBToGRDBMigration: Migration { + static let target: TargetMigrations.Identifier = .snodeKit + static let identifier: String = "YDBToGRDBMigration" + 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 { + 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( + SSKLegacy.Snode.self, + forClassName: "SessionSnodeKit.Snode" + ) + + dbConnection.read { transaction in + // MARK: --lastSnodePoolRefreshDate + + lastSnodePoolRefreshDate = transaction.object( + forKey: SSKLegacy.lastSnodePoolRefreshDateKey, + inCollection: SSKLegacy.lastSnodePoolRefreshDateCollection + ) as? Date + + // 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, + let path0Snode2 = transaction.object(forKey: "0-2", inCollection: SSKLegacy.onionRequestPathCollection) as? SSKLegacy.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: 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) + snodeResult.insert(path1Snode2) + snodeSetResult["\(SnodeSet.onionRequestPathPrefix)1"] = [ path1Snode0, path1Snode1, path1Snode2 ] + } + } + Storage.update(progress: 0.02, for: self, in: target) + + // MARK: --SnodePool + + transaction.enumerateKeysAndObjects(inCollection: SSKLegacy.snodePoolCollection) { _, object, _ in + guard let snode = object as? SSKLegacy.Snode else { return } + snodeResult.insert(snode) + } + + // 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, 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 + var swarmCollections: Set = [] + var collectionIndex: CGFloat = 0 + + transaction.enumerateCollections { collectionName, _ in + if collectionName.starts(with: SSKLegacy.swarmCollectionPrefix) { + swarmCollections.insert(collectionName.substring(from: SSKLegacy.swarmCollectionPrefix.count)) + } + + collectionIndex += 1 + + Storage.update( + progress: min( + swarmCompleteProgress, + ((collectionIndex / roughNumCollections) * (swarmCompleteProgress - startProgress)) + ), + for: self, + in: target + ) + } + Storage.update(progress: swarmCompleteProgress, for: self, in: target) + + for swarmCollection in swarmCollections { + let collection: String = "\(SSKLegacy.swarmCollectionPrefix)\(swarmCollection)" + + transaction.enumerateKeysAndObjects(inCollection: collection) { _, object, _ in + guard let snode = object as? SSKLegacy.Snode else { return } + snodeResult.insert(snode) + snodeSetResult[swarmCollection] = (snodeSetResult[swarmCollection] ?? Set()).inserting(snode) + } + } + Storage.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 + } + Storage.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) + } + Storage.update(progress: 0.94, for: self, in: target) + } + + // MARK: - Insert into GRDB + + try autoreleasepool { + // MARK: --lastSnodePoolRefreshDate + + db[.lastSnodePoolRefreshDate] = lastSnodePoolRefreshDate + + // MARK: --SnodePool + + try snodeResult.forEach { legacySnode in + try Snode( + address: legacySnode.address, + port: legacySnode.port, + ed25519PublicKey: legacySnode.publicKeySet.ed25519Key, + x25519PublicKey: legacySnode.publicKeySet.x25519Key + ).insert(db) + } + Storage.update(progress: 0.96, for: self, in: target) + + // MARK: --SnodeSets + + 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) + } + } + Storage.update(progress: 0.98, for: self, in: target) + } + + try autoreleasepool { + // MARK: --Received Messages + + try receivedMessageResults.forEach { key, hashes in + try hashes.forEach { hash in + _ = try SnodeReceivedMessageInfo( + key: key, + hash: hash, + expirationDateMs: SnodeReceivedMessage.defaultExpirationSeconds + ).inserted(db) + } + } + Storage.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) + + _ = try SnodeReceivedMessageInfo( + key: key, + hash: data.hash, + expirationDateMs: (expirationDateMs > 0 ? + expirationDateMs : + SnodeReceivedMessage.defaultExpirationSeconds + ) + ).inserted(db) + } + } + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionSnodeKit/Database/Models/Snode.swift b/SessionSnodeKit/Database/Models/Snode.swift new file mode 100644 index 000000000..9bcf6bc8c --- /dev/null +++ b/SessionSnodeKit/Database/Models/Snode.swift @@ -0,0 +1,136 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +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 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 SnodeAPIError.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: - GRDB Interactions + +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) + } + + 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) + + try SnodeSet( + key: key, + nodeIndex: nodeIndex, + address: node.address, + port: node.port + ).save(db) + } + } +} + +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 new file mode 100644 index 000000000..16b66672d --- /dev/null +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -0,0 +1,126 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +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:** 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 + + public mutating func didInsert(with rowID: Int64, for column: String?) { + self.id = rowID + } +} + +// MARK: - Convenience + +public extension SnodeReceivedMessageInfo { + 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, namespace: namespace) + self.hash = hash + self.expirationDateMs = (expirationDateMs ?? 0) + } +} + +// MARK: - GRDB Interactions + +public extension SnodeReceivedMessageInfo { + static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String) { + // 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 + try SnodeReceivedMessageInfo + .filter(rowIds.contains(Column.rowID)) + .deleteAll(db) + } + } + + /// 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, namespace: Int, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { + 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)) + .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/Database/Models/SnodeSet.swift b/SessionSnodeKit/Database/Models/SnodeSet.swift new file mode 100644 index 000000000..209a5d95f --- /dev/null +++ b/SessionSnodeKit/Database/Models/SnodeSet.swift @@ -0,0 +1,28 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public struct SnodeSet: Codable, FetchableRecord, EncodableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static let onionRequestPathPrefix = "OnionRequestPath-" + public static var databaseTableName: String { "snodeSet" } + 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 + } + + public let key: String + public let nodeIndex: Int + public let address: String + public let port: UInt16 + + public var node: QueryInterfaceRequest { + request(for: SnodeSet.node) + } +} diff --git a/SessionSnodeKit/Database/Types/SSKSetting.swift b/SessionSnodeKit/Database/Types/SSKSetting.swift new file mode 100644 index 000000000..7db9629d9 --- /dev/null +++ b/SessionSnodeKit/Database/Types/SSKSetting.swift @@ -0,0 +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/GetSnodePoolJob.swift b/SessionSnodeKit/GetSnodePoolJob.swift new file mode 100644 index 000000000..af0c61d07 --- /dev/null +++ b/SessionSnodeKit/GetSnodePoolJob.swift @@ -0,0 +1,52 @@ +// 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, + queue: DispatchQueue, + success: @escaping (Job, Bool) -> (), + 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(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/SessionSnodeKit/Models/OnionRequestAPIDestination.swift b/SessionSnodeKit/Models/OnionRequestAPIDestination.swift new file mode 100644 index 000000000..8483ce347 --- /dev/null +++ b/SessionSnodeKit/Models/OnionRequestAPIDestination.swift @@ -0,0 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +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 new file mode 100644 index 000000000..3b75fe124 --- /dev/null +++ b/SessionSnodeKit/Models/OnionRequestAPIError.swift @@ -0,0 +1,33 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +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 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)." + + 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/OnionRequestAPIVersion.swift b/SessionSnodeKit/Models/OnionRequestAPIVersion.swift new file mode 100644 index 000000000..f4bc31a8f --- /dev/null +++ b/SessionSnodeKit/Models/OnionRequestAPIVersion.swift @@ -0,0 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum OnionRequestAPIVersion: String, Codable { + case v2 = "/loki/v2/lsrpc" + case v3 = "/loki/v3/lsrpc" + case v4 = "/oxen/v4/lsrpc" +} 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/SnodeAPIEndpoint.swift b/SessionSnodeKit/Models/SnodeAPIEndpoint.swift new file mode 100644 index 000000000..63ffd5334 --- /dev/null +++ b/SessionSnodeKit/Models/SnodeAPIEndpoint.swift @@ -0,0 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum SnodeAPIEndpoint: 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" + case expire = "expire" + case batch = "batch" + case sequence = "sequence" +} diff --git a/SessionSnodeKit/Models/SnodeAPIError.swift b/SessionSnodeKit/Models/SnodeAPIError.swift new file mode 100644 index 000000000..60403b0ec --- /dev/null +++ b/SessionSnodeKit/Models/SnodeAPIError.swift @@ -0,0 +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 new file mode 100644 index 000000000..d91e59183 --- /dev/null +++ b/SessionSnodeKit/Models/SnodePoolResponse.swift @@ -0,0 +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 new file mode 100644 index 000000000..bf2832f5c --- /dev/null +++ b/SessionSnodeKit/Models/SnodeReceivedMessage.swift @@ -0,0 +1,39 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 + + init?(snode: Snode, publicKey: String, namespace: Int, 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 + } + + let expirationDateMs: Int64? = (rawMessage["expiration"] as? Int64) + self.info = SnodeReceivedMessageInfo( + snode: snode, + publicKey: publicKey, + namespace: namespace, + hash: hash, + expirationDateMs: (expirationDateMs ?? SnodeReceivedMessage.defaultExpirationSeconds) + ) + 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..727d79191 --- /dev/null +++ b/SessionSnodeKit/Models/SwarmSnode.swift @@ -0,0 +1,55 @@ +// 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 = "port_https" // Note: The 'port' key was deprecated inplace of the 'port_https' key + 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) + + guard address != "0.0.0.0" else { throw SnodeAPIError.invalidIP } + + self = SwarmSnode( + 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 + } + } + + 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..8652b28d8 100644 --- a/SessionSnodeKit/OnionRequestAPI+Encryption.swift +++ b/SessionSnodeKit/OnionRequestAPI+Encryption.swift @@ -1,3 +1,5 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import CryptoSwift import PromiseKit import SessionUtilitiesKit @@ -14,62 +16,71 @@ 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: JSON, for destination: Destination) -> Promise { + static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) -> 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 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 .server(_, _, let serverX25519PublicKey, _, _): + let result: AESGCM.EncryptionResult = try AESGCM.encrypt(payload, for: serverX25519PublicKey) + seal.fulfill(result) } - } catch (let error) { + } + catch (let error) { seal.reject(error) } } + return promise } /// 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.publicKeySet.ed25519Key - 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.publicKeySet.x25519Key - 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 1a8b36ee4..3efd5d4ba 100644 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ b/SessionSnodeKit/OnionRequestAPI.swift @@ -1,19 +1,53 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import CryptoSwift +import GRDB import PromiseKit import SessionUtilitiesKit +public protocol OnionRequestAPIType { + 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?)> +} + +public extension OnionRequestAPIType { + static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> Promise<(OnionRequestResponseInfoType, Data?)> { + sendOnionRequest(request, to: server, using: .v4, with: x25519PublicKey) + } +} + /// 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 +public enum OnionRequestAPI: OnionRequestAPIType { + 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: [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 + + // 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]]? = Storage.shared.read { db in + try? Snode.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 @@ -26,71 +60,42 @@ 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(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 = [Snode] - - // MARK: Onion Building Result + + // 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(OnionRequestAPIError.missingSnodeVersion) + } + + if version >= "2.0.7" { + seal.fulfill(()) + } + else { + SNLog("Unsupported snode version: \(version).") + seal.reject(OnionRequestAPIError.unsupportedSnodeVersion(version)) + } + } + .catch2 { error in + seal.reject(error) } - }.catch2 { error in - seal.reject(error) - } } + return promise } @@ -99,25 +104,41 @@ public enum OnionRequestAPI { private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> Promise> { if guardSnodes.count >= targetGuardSnodeCount { return Promise> { $0.fulfill(guardSnodes) } - } else { + } + 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) } + + guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { + 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) } } + // randomElement() uses the system's default random generator, which + // is cryptographically secure + guard let candidate = unusedSnodes.randomElement() else { + return Promise { $0.reject(OnionRequestAPIError.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 +147,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 OnionRequestAPIError.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 + + Storage.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,51 +198,69 @@ 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: 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 { - return Promise { $0.fulfill(paths.filter { !$0.contains(snode) }.randomElement()!) } - } else { - return Promise { $0.fulfill(paths.randomElement()!) } - } - } else if !paths.isEmpty { + 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 { 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()! + 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()!) } } - } else { + else { + buildPaths(reusing: paths) // Re-build paths in the background + + guard let path: [Snode] = paths.randomElement() else { + return Promise(error: OnionRequestAPIError.insufficientSnodes) + } + + return Promise { $0.fulfill(path) } + } + } + 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 OnionRequestAPIError.insufficientSnodes } + + guard let path: [Snode] = paths.randomElement() else { + throw OnionRequestAPIError.insufficientSnodes + } + + return path } } } @@ -237,20 +286,21 @@ public enum OnionRequestAPI { 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 oldPaths.remove(at: pathIndex) let newPaths = oldPaths + [ path ] paths = newPaths - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in + + Storage.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,219 +309,463 @@ 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 { + + Storage.shared.write { db in + guard !paths.isEmpty else { SNLog("Clearing onion request paths.") - SNSnodeKitConfiguration.shared.storage.setOnionRequestPaths(to: [], using: transaction) + try? Snode.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 { + 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! 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 = OnionRequestAPIDestination.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 + // 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 + 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) } + + /// **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 } + + return data + } + .recover2 { error -> Promise in + guard case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, let data, _) = error else { + throw error + } + + throw SnodeAPI.handleError(withStatusCode: statusCode, data: data, 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, 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) } - 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) - } - 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" - } + + 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: OnionRequestAPIError.invalidRequestInfo) } - let payload: JSON = [ - "body" : parametersAsString, - "endpoint" : endpoint, - "method" : request.httpMethod!, - "headers" : headers - ] - let destination = Destination.server(host: host, target: target, x25519PublicKey: x25519PublicKey, scheme: scheme, port: port) - let promise = sendOnionRequest(with: payload, to: destination) + + 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).") } return promise } - public static func sendOnionRequest(with payload: JSON, to destination: Destination) -> Promise { - let (promise, seal) = Promise.pending() + 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).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) { - 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 { 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 statusCode == 401 { // Signature verification failed - SNLog("Failed to verify the signature.") - seal.reject(SnodeAPI.Error.signatureVerificationFailed) - } 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) + 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 + 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 + ) + } + .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 data) = 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 + SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode) // Intentionally don't throw } + drop(path) - } else { + } + else { OnionRequestAPI.pathFailureCount[path] = pathFailureCount } } + 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) - } 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 } + + // MARK: - Version Handling + + private static func generatePayload(for request: URLRequest, with version: OnionRequestAPIVersion) -> Data? { + 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 { + 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 jsonData + + // 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 + let endpoint: String = url.path + .appending(url.query.map { value in "?\(value)" }) + + let requestInfo: RequestInfo = RequestInfo( + method: (request.httpMethod ?? "GET"), // The default (if nil) is 'GET' + endpoint: endpoint, + headers: (request.allHTTPHeaderFields ?? [:]) + .setting( + "Content-Type", + (request.httpBody == nil ? nil : + // Default to JSON if not defined + ((request.allHTTPHeaderFields ?? [:])["Content-Type"] ?? "application/json") + ) + ) + .removingValue(forKey: "User-Agent") + ) + + /// 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, let bodyCountData: Data = "\(body.count):".data(using: .ascii) { + return (prefixData + requestInfoData + bodyCountData + body + suffixData) + } + + return (prefixData + requestInfoData + suffixData) + } + } + + private static func handleResponse( + responseData: Data, + destinationSymmetricKey: Data, + version: OnionRequestAPIVersion, + destination: OnionRequestAPIDestination, + 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(SnodeAPIError.clockOutOfSync) + } + + if statusCode == 401 { // Signature verification failed + SNLog("Failed to verify the signature.") + return seal.reject(SnodeAPIError.signatureVerificationFailed) + } + + if let bodyAsString = json["body"] as? String { + 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 { + let offset = timestamp - Int64(floor(Date().timeIntervalSince1970 * 1000)) + SnodeAPI.clockOffset = offset + } + + guard 200...299 ~= statusCode else { + 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(OnionRequestAPIError.httpRequestFailedAtDestination(statusCode: UInt(statusCode), data: data, 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)) + } + + // 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]), let suffixData: Data = "e".data(using: .utf8) else { + return seal.reject(HTTP.Error.invalidResponse) + } + + let dataBytes: Array = Array(data) + let dataEndIndex: Int = (dataBytes.count - suffixData.count) + let dataStartIndex: Int = (dataEndIndex - finalDataLength) + let finalDataBytes: ArraySlice = dataBytes[dataStartIndex.. 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 eabce3993..2e5b5fc1f 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -1,9 +1,12 @@ -import PromiseKit -import SessionUtilitiesKit -import Sodium +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -@objc(SNSnodeAPI) -public final class SnodeAPI : NSObject { +import Foundation +import PromiseKit +import Sodium +import GRDB +import SessionUtilitiesKit + +public final class SnodeAPI { private static let sodium = Sodium() private static var hasLoadedSnodePool = false @@ -11,7 +14,7 @@ public final class SnodeAPI : NSObject { 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: [Snode: UInt] = [:] /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. internal static var snodePool: Set = [] @@ -21,76 +24,53 @@ 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: - Namespaces - // MARK: Namespaces public static let defaultNamespace = 0 public static let closedGroupNamespace = -10 public static let configNamespace = 5 - // MARK: Hardfork version + // MARK: - Hardfork version + public static var hardfork = UserDefaults.standard[.hardfork] public static var softfork = UserDefaults.standard[.softfork] - // MARK: Settings + // MARK: - Settings + private static let maxRetryCount: UInt = 8 private static let minSwarmSnodeCount = 3 private static let seedNodePool: Set = Features.useTestnet ? [ "http://public.loki.foundation:38157" ] : [ "https://storage.seed1.loki.network:4433", "https://storage.seed3.loki.network:4433", "https://public.loki.foundation:4433" ] 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 - case signatureVerificationFailed - // 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." - // 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]> - public typealias RawResponse = Any - public typealias RawResponsePromise = Promise - // MARK: Snode Pool Interaction + + private static var hasInsufficientSnodes: Bool { snodePool.count < minSnodePoolCount } + private static func loadSnodePoolIfNeeded() { guard !hasLoadedSnodePool else { return } - snodePool = SNSnodeKitConfiguration.shared.storage.getSnodePool() + + Storage.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 { + Storage.shared.write { db in + _ = try? Snode.deleteAll(db) + newValue.forEach { try? $0.save(db) } } } } @@ -106,6 +86,7 @@ public final class SnodeAPI : NSObject { @objc public static func clearSnodePool() { snodePool.removeAll() + Threading.workQueue.async { setSnodePool(to: []) } @@ -114,7 +95,11 @@ 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) + + Storage.shared.read { db in + swarmCache[publicKey] = ((try? Snode.fetchSet(db, publicKey: publicKey)) ?? []) + } + loadedSwarms.insert(publicKey) } @@ -124,8 +109,9 @@ public final class SnodeAPI : NSObject { #endif swarmCache[publicKey] = newValue guard persist else { return } - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - SNSnodeKitConfiguration.shared.storage.setSwarm(to: newValue, for: publicKey, using: transaction) + + Storage.shared.write { db in + try? newValue.save(db, key: publicKey) } } @@ -140,15 +126,27 @@ 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: SnodeAPIEndpoint, on snode: Snode, associatedWith publicKey: String? = nil, parameters: JSON) -> Promise { if Features.useOnionRequests { - return OnionRequestAPI.sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey) - .map2 { json in - if let hf = json["hf"] as? [Int] { + return OnionRequestAPI + .sendOnionRequest( + to: snode, + invoking: method, + with: parameters, + associatedWith: publicKey + ) + .map2 { responseData in + guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { + throw HTTP.Error.invalidJSON + } + + if let hf = responseJson["hf"] as? [Int] { if hf[1] > softfork { softfork = hf[1] UserDefaults.standard[.softfork] = softfork } + if hf[0] > hardfork { hardfork = hf[0] UserDefaults.standard[.hardfork] = hardfork @@ -156,21 +154,26 @@ public final class SnodeAPI : NSObject { UserDefaults.standard[.softfork] = softfork } } - return json as Any + + return responseData } - } 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 } } @@ -184,38 +187,45 @@ public final class SnodeAPI : NSObject { 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() + 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 snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else { + throw SnodeAPIError.snodePoolUpdatingFailed } - return Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) - }) - } - }.done2 { snodePool in + + return snodePool.result + .serviceNodeStates + .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 } @@ -223,104 +233,120 @@ public final class SnodeAPI : NSObject { var snodePool = SnodeAPI.snodePool 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 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 { - 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 snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else { + throw SnodeAPIError.snodePoolUpdatingFailed } - return Snode(address: "https://\(address)", port: UInt16(port), publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) - }) - } + + return snodePool.result + .serviceNodeStates + .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([Snode](result)[0..<256]) : result - } else { - throw Error.inconsistentSnodePools - } + 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 SnodeAPIError.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 hasCachedSnodesInclusingExpired() -> Bool { + loadSnodePoolIfNeeded() + + return !hasInsufficientSnodes } public static func getSnodePool() -> Promise> { loadSnodePoolIfNeeded() let now = Date() - let hasSnodePoolExpired = given(Storage.shared.getLastSnodePoolRefreshDate()) { 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> - if snodePool.count < minSnodePoolCount { - promise = getSnodePoolFromSeedNode() - } else { - promise = getSnodePoolFromSnode().recover2 { _ in - getSnodePoolFromSeedNode() - } - } - getSnodePoolPromise = promise - promise.map2 { snodePool -> Set in - if snodePool.isEmpty { - throw Error.snodePoolUpdatingFailed - } else { - 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) - }) - return promise - } - promise.done2 { _ in - getSnodePoolPromise = nil - } - promise.catch2 { _ in - getSnodePoolPromise = nil - } - return promise - } else { + let hasSnodePoolExpired = given(Storage.shared[.lastSnodePoolRefreshDate]) { + now.timeIntervalSince($0) > 2 * 60 * 60 + }.defaulting(to: true) + let snodePool: Set = SnodeAPI.snodePool + + 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() + } + } + + 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 } public static func getSessionID(for onsName: String) -> Promise { @@ -330,7 +356,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() @@ -349,46 +378,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 rawResponse): - guard let json = rawResponse as? JSON, let intermediate = json["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 } @@ -399,267 +459,525 @@ public final class SnodeAPI : NSObject { 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 + } + + SNLog("Getting swarm for: \((publicKey == getUserHexEncodedPublicKey()) ? "self" : 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) } - }.map2 { rawSnodes in - let swarm = parseSnodes(from: rawSnodes) + } + .map2 { responseData in + let swarm = parseSnodes(from: responseData) + setSwarm(to: swarm, for: publicKey) return swarm } - } } + + // MARK: - Retrieve - // MARK: Retrieve // Not in use until we can batch delete and store config messages - public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise { - let (promise, seal) = RawResponsePromise.pending() - Threading.workQueue.async { - getMessagesWithAuthentication(from: snode, associatedWith: publicKey, namespace: configNamespace).done2 { - seal.fulfill($0) - }.catch2 { - seal.reject($0) - } - } - return promise - } - - public static func getRawMessages(from snode: Snode, associatedWith publicKey: String, authenticated: Bool = true) -> RawResponsePromise { - let (promise, seal) = RawResponsePromise.pending() - Threading.workQueue.async { - let retrievePromise = authenticated ? getMessagesWithAuthentication(from: snode, associatedWith: publicKey, namespace: defaultNamespace) : getMessagesUnauthenticated(from: snode, associatedWith: publicKey) - retrievePromise.done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } - } - return promise - } - - public static func getRawClosedGroupMessagesFromDefaultNamespace(from snode: Snode, associatedWith publicKey: String) -> RawResponsePromise { - let (promise, seal) = RawResponsePromise.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) -> RawResponsePromise { - let storage = SNSnodeKitConfiguration.shared.storage + public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { + let (promise, seal) = Promise<[SnodeReceivedMessage]>.pending() - // 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. + Threading.workQueue.async { + getMessagesWithAuthentication(from: snode, associatedWith: publicKey, namespace: configNamespace) + .done2 { + seal.fulfill($0) + } + .catch2 { + seal.reject($0) + } + } + + return promise + } + + 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) : + getMessagesUnauthenticated(from: snode, associatedWith: publicKey) + ) + + retrievePromise + .done2 { seal.fulfill($0) } + .catch2 { seal.reject($0) } + } + + return promise + } + + public static func getClosedGroupMessagesFromDefaultNamespace(from snode: Snode, associatedWith publicKey: String) -> Promise<[SnodeReceivedMessage]> { + 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<[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 = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + return Promise(error: SnodeAPIError.noKeyPair) + } - guard let userED25519KeyPair = storage.getUserED25519KeyPair() else { return Promise(error: Error.noKeyPair) } // Get last message hash - storage.pruneLastMessageHashInfoIfExpired(for: snode, namespace: namespace, associatedWith: publicKey) - let lastHash = storage.getLastMessageHash(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 ?? "" + // Construct signature - let timestamp = UInt64(Int64(NSDate.millisecondTimestamp()) + SnodeAPI.clockOffset) + let timestamp = UInt64(Int64(floor(Date().timeIntervalSince1970 * 1000)) + SnodeAPI.clockOffset) let ed25519PublicKey = userED25519KeyPair.publicKey.toHexString() - let namespaceVerificationString = namespace == defaultNamespace ? "" : String(namespace) - 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) } + let namespaceVerificationString = (namespace == defaultNamespace ? "" : String(namespace)) + + 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: SnodeAPIError.signingFailed) } + // Make the request let parameters: JSON = [ - "pubKey" : Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey, + "pubKey": Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey, "namespace": namespace, - "lastHash" : lastHash, - "timestamp" : timestamp, - "pubkey_ed25519" : ed25519PublicKey, - "signature" : signature.toBase64() + "lastHash": lastHash, + "timestamp": timestamp, + "pubkey_ed25519": ed25519PublicKey, + "signature": signature.toBase64() ] - return invoke(.getMessages, on: snode, associatedWith: publicKey, parameters: parameters) - } - - private static func getMessagesUnauthenticated(from snode: Snode, associatedWith publicKey: String, namespace: Int = closedGroupNamespace) -> RawResponsePromise { - let storage = SNSnodeKitConfiguration.shared.storage + 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<[SnodeReceivedMessage]> { // Get last message hash - storage.pruneLastMessageHashInfoIfExpired(for: snode, namespace: namespace, associatedWith: publicKey) - let lastHash = storage.getLastMessageHash(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 var parameters: JSON = [ - "pubKey" : Features.useTestnet ? publicKey.removing05PrefixIfNeeded() : publicKey, - "lastHash" : lastHash, + "pubKey": (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey), + "lastHash": lastHash, ] + // Don't include namespace if polling for 0 with no authentication if namespace != defaultNamespace { parameters["namespace"] = namespace } + 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 - public static func sendMessage(_ message: SnodeMessage, isClosedGroupMessage: Bool, isConfigMessage: Bool) -> Promise> { + public static func sendMessage(_ message: SnodeMessage, isClosedGroupMessage: Bool, isConfigMessage: Bool) -> Promise>> { return sendMessageUnauthenticated(message, isClosedGroupMessage: isClosedGroupMessage) } // 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 + private static func sendMessageWithAuthentication(_ message: SnodeMessage, namespace: Int) -> 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) } - guard let userED25519KeyPair = storage.getUserED25519KeyPair() else { return Promise(error: Error.noKeyPair) } - // Construct signature - let timestamp = UInt64(Int64(NSDate.millisecondTimestamp()) + SnodeAPI.clockOffset) - let ed25519PublicKey = userED25519KeyPair.publicKey.toHexString() - 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) } - // Make the request - let (promise, seal) = Promise>.pending() - let publicKey = Features.useTestnet ? message.recipient.removing05PrefixIfNeeded() : message.recipient - Threading.workQueue.async { - getTargetSnodes(for: publicKey).map2 { targetSnodes in - var parameters = message.toJSON() - parameters["namespace"] = namespace - parameters["sig_timestamp"] = timestamp - parameters["pubkey_ed25519"] = ed25519PublicKey - parameters["signature"] = signature.toBase64() - 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) } + guard let userED25519KeyPair: Box.KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + return Promise(error: SnodeAPIError.noKeyPair) } + + // Construct signature + let timestamp = UInt64(Int64(floor(Date().timeIntervalSince1970 * 1000)) + SnodeAPI.clockOffset) + let ed25519PublicKey = userED25519KeyPair.publicKey.toHexString() + + 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: SnodeAPIError.signingFailed) } + + // Make the request + let (promise, seal) = Promise>>.pending() + let publicKey = (Features.useTestnet ? message.recipient.removingIdPrefixIfNeeded() : message.recipient) + + Threading.workQueue.async { + getTargetSnodes(for: publicKey) + .map2 { targetSnodes in + var parameters: JSON = messageJson + parameters["namespace"] = namespace + parameters["sig_timestamp"] = timestamp + parameters["pubkey_ed25519"] = ed25519PublicKey + parameters["signature"] = signature.toBase64() + + 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 } - private static func sendMessageUnauthenticated(_ message: SnodeMessage, isClosedGroupMessage: Bool) -> Promise> { - let (promise, seal) = Promise>.pending() - let publicKey = Features.useTestnet ? message.recipient.removing05PrefixIfNeeded() : message.recipient + 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 + Threading.workQueue.async { - getTargetSnodes(for: publicKey).map2 { targetSnodes in - var rawResponsePromises: Set = Set() - var parameters = message.toJSON() - parameters["namespace"] = isClosedGroupMessage ? closedGroupNamespace : defaultNamespace - for targetSnode in targetSnodes { - let rawResponsePromise = attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - invoke(.sendMessage, on: targetSnode, associatedWith: publicKey, parameters: parameters) - } - rawResponsePromises.insert(rawResponsePromise) - } - - // Send closed group messages to default namespace as well - if hardfork == 19 && softfork == 0 && isClosedGroupMessage { - parameters["namespace"] = defaultNamespace + getTargetSnodes(for: publicKey) + .map2 { targetSnodes in + var rawResponsePromises: Set> = Set() + var parameters: JSON = messageJson + parameters["namespace"] = (isClosedGroupMessage ? closedGroupNamespace : defaultNamespace) + for targetSnode in targetSnodes { let rawResponsePromise = attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { invoke(.sendMessage, on: targetSnode, associatedWith: publicKey, parameters: parameters) } rawResponsePromises.insert(rawResponsePromise) } + + // Send closed group messages to default namespace as well + if hardfork == 19 && softfork == 0 && isClosedGroupMessage { + parameters["namespace"] = defaultNamespace + for targetSnode in targetSnodes { + let rawResponsePromise = attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { + invoke(.sendMessage, on: targetSnode, associatedWith: publicKey, parameters: parameters) + } + rawResponsePromises.insert(rawResponsePromise) + } + } + + return rawResponsePromises } - - return rawResponsePromises - }.done2 { seal.fulfill($0) }.catch2 { seal.reject($0) } + .done2 { seal.fulfill($0) } + .catch2 { seal.reject($0) } } + 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 - @objc(deleteMessageForPublickKey:serverHashes:) - public static func objc_deleteMessage(publicKey: String, serverHashes: [String]) -> AnyPromise { - 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 - 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)! - 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 - } - } - return result - } - } - } + public static func deleteMessage(publicKey: String, serverHashes: [String]) -> Promise<[String: Bool]> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Promise(error: SnodeAPIError.noKeyPair) } - } - - /// 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) } + + let publicKey = (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey) + 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 = (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, - "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] = [:] + 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 signature = sodium.sign.signature( + message: verificationBytes, + secretKey: userED25519KeyPair.secretKey + ) + else { + throw SnodeAPIError.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 { 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 + + 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)!)) + 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 verificationBytes = userX25519PublicKey.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)!) + ) + result[snodePublicKey] = isValid - } else { + } + 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 { + } + else { SNLog("Couldn't delete data from: \(snodePublicKey).") } result[snodePublicKey] = false } } + 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]> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Promise(error: SnodeAPIError.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 = (SnodeAPIEndpoint.clearAllData.rawValue + String(timestamp)).data(using: String.Encoding.utf8)! + + guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { + throw SnodeAPIError.signingFailed + } + + let parameters: JSON = [ + "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 { 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() + ] + .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 + } + } } } } @@ -670,74 +988,38 @@ 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 [] } - 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 Snode(address: "https://\(address)", port: port, publicKeySet: Snode.KeySet(ed25519Key: ed25519PublicKey, x25519Key: x25519PublicKey)) - }) - } - - public static func parseRawMessagesResponse(_ rawResponse: Any, from snode: 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: Snode, namespace: Int, 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, namespace: namespace, 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 rawSnodes = responseJson["snodes"] as? [JSON] else { + SNLog("Failed to parse snodes from: \(responseJson).") + return [] } - } - - public static func updateReceivedMessages(from messages: [JSON], associatedWith publicKey: String) { - let oldReceivedMessages = SNSnodeKitConfiguration.shared.storage.getReceivedMessages(for: publicKey) - var newReceivedMessages = oldReceivedMessages - for message in messages { - guard let hash = message["hash"] as? String else { continue } - newReceivedMessages.insert(hash) + + guard let snodeData: Data = try? JSONSerialization.data(withJSONObject: rawSnodes, options: []) else { + return [] } - if oldReceivedMessages != newReceivedMessages { - SNSnodeKitConfiguration.shared.storage.writeSync { transaction in - SNSnodeKitConfiguration.shared.storage.setReceivedMessages(to: newReceivedMessages, for: publicKey, using: transaction) - } + + // 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() } - } - - 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 - } - return result + + return ((try? JSONDecoder().decode([Failable].self, from: snodeData)) ?? []) + .compactMap { $0.value } + .asSet() } // 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 @@ -756,40 +1038,51 @@ public final class SnodeAPI : NSObject { SnodeAPI.snodeFailureCount[snode] = 0 } } + switch statusCode { - case 500, 502, 503: - // The snode is unreachable - handleBadSnode() - case 404: - // May caused by invalid open groups - SNLog("Can't reach the server.") - 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 404: + // May caused by invalid open groups + SNLog("Can't reach the server.") + + case 406: + SNLog("The user's clock is out of sync with the service node network.") + return SnodeAPIError.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/SessionSnodeKit/SnodeMessage.swift b/SessionSnodeKit/SnodeMessage.swift index f767a7d35..0b4c9cb7c 100644 --- a/SessionSnodeKit/SnodeMessage.swift +++ b/SessionSnodeKit/SnodeMessage.swift @@ -1,54 +1,65 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + 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.removingIdPrefixIfNeeded() : 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/SessionSnodeKit/Storage+OnionRequests.swift b/SessionSnodeKit/Storage+OnionRequests.swift deleted file mode 100644 index 456507b7e..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? Snode, - let path0Snode1 = transaction.object(forKey: "0-1", inCollection: collection) as? Snode, - let path0Snode2 = transaction.object(forKey: "0-2", inCollection: collection) as? Snode { - result.append([ path0Snode0, path0Snode1, path0Snode2 ]) - if - let path1Snode0 = transaction.object(forKey: "1-0", inCollection: collection) as? Snode, - let path1Snode1 = transaction.object(forKey: "1-1", inCollection: collection) as? Snode, - let path1Snode2 = transaction.object(forKey: "1-2", inCollection: collection) as? 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 cbccbc0a6..000000000 --- a/SessionSnodeKit/Storage+SnodeAPI.swift +++ /dev/null @@ -1,139 +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? 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? 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 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: Snode, namespace: Int, associatedWith publicKey: String) -> JSON? { - let key = namespace == SnodeAPI.defaultNamespace ? "\(snode.address):\(snode.port).\(publicKey)" : "\(snode.address):\(snode.port).\(publicKey).\(namespace)" - 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: Snode, namespace: Int, associatedWith publicKey: String) -> String? { - return getLastMessageHashInfo(for: snode, namespace: namespace, associatedWith: publicKey)?["hash"] as? String - } - - public func setLastMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String, to lastMessageHashInfo: JSON, using transaction: Any) { - let key = namespace == SnodeAPI.defaultNamespace ? "\(snode.address):\(snode.port).\(publicKey)" : "\(snode.address):\(snode.port).\(publicKey).\(namespace)" - 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, namespace: Int, associatedWith publicKey: String) { - guard let lastMessageHashInfo = getLastMessageHashInfo(for: snode, namespace: namespace, 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, namespace: namespace, associatedWith: publicKey, using: transaction) - } - } - } - - public func removeLastMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String, using transaction: Any) { - let key = namespace == SnodeAPI.defaultNamespace ? "\(snode.address):\(snode.port).\(publicKey)" : "\(snode.address):\(snode.port).\(publicKey).\(namespace)" - (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 04367a847..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: Snode, namespace: Int, associatedWith publicKey: String) -> String? - func setLastMessageHashInfo(for snode: Snode, namespace: Int, associatedWith publicKey: String, to lastMessageHashInfo: JSON, using transaction: Any) - func pruneLastMessageHashInfoIfExpired(for snode: Snode, namespace: Int, associatedWith publicKey: String) - func getReceivedMessages(for publicKey: String) -> Set - func setReceivedMessages(to receivedMessages: Set, for publicKey: String, using transaction: Any) -} 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/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/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/SessionUIKit/Components/SearchBar.swift b/SessionUIKit/Components/SearchBar.swift index f4fa0b992..830f0973f 100644 --- a/SessionUIKit/Components/SearchBar.swift +++ b/SessionUIKit/Components/SearchBar.swift @@ -23,15 +23,10 @@ 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 ]) + 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/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/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/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index ca72ad9df..b0f728ee0 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -1,20 +1,36 @@ +// 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 } } public enum SNUtilitiesKit { // Just to make the external API nice + public static func migrations() -> TargetMigrations { + return TargetMigrations( + identifier: .utilitiesKit, + migrations: [ + [ + // 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_SetupStandardJobs.self, + _003_YDBToGRDBMigration.self + ] + ] + ) + } - 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/Crypto/ECKeyPair+Hexadecimal.swift b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift index 585d1965f..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 "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 '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 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 && 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/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/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift new file mode 100644 index 000000000..5d3756d74 --- /dev/null +++ b/SessionUtilitiesKit/Database/LegacyDatabase/SUKLegacy.swift @@ -0,0 +1,167 @@ +// 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" + 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: - Database Functions + + public 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? SSKDefaultKeychainStorage.shared.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 + } + + 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) + 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 + private static let publicKeyKey: String = "TSECKeyPairPublicKey" + private static let privateKeyKey: String = "TSECKeyPairPrivateKey" + + 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 + + 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/_001_InitialSetupMigration.swift b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift new file mode 100644 index 000000000..797e7c7a4 --- /dev/null +++ b/SessionUtilitiesKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -0,0 +1,73 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +enum _001_InitialSetupMigration: Migration { + static let target: TargetMigrations.Identifier = .utilitiesKit + static let identifier: String = "initialSetup" + 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 + t.column(.variant, .text) + .notNull() + .unique() + .primaryKey() + 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(.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 + .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: JobDependencies.self) { t in + t.column(.jobId, .integer) + .notNull() + .references(Job.self, onDelete: .cascade) // Delete if Job deleted + t.column(.dependantId, .integer) + .indexed() // Quicker querying + .references(Job.self, onDelete: .setNull) // Delete if Job deleted + + t.primaryKey([.jobId, .dependantId]) + } + + try db.create(table: Setting.self) { t in + t.column(.key, .text) + .notNull() + .unique() + .primaryKey() + t.column(.value, .blob).notNull() + } + + 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 new file mode 100644 index 000000000..ea056aa28 --- /dev/null +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -0,0 +1,35 @@ +// 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 target: TargetMigrations.Identifier = .utilitiesKit + static let identifier: String = "SetupStandardJobs" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 + + 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) + + // 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, + shouldSkipLaunchBecomeActive: true + ).inserted(db) + } + + 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 new file mode 100644 index 000000000..8dfd91deb --- /dev/null +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -0,0 +1,119 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import YapDatabase + +enum _003_YDBToGRDBMigration: Migration { + static let target: TargetMigrations.Identifier = .utilitiesKit + static let identifier: String = "YDBToGRDBMigration" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 + + static func migrate(_ db: Database) throws { + 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? + var seedHexString: String? + var userEd25519SecretKeyHexString: String? + var userEd25519PublicKeyHexString: String? + var userX25519KeyPair: SUKLegacy.KeyPair? + + // Map the Legacy types for the NSKeyedUnarchiver + NSKeyedUnarchiver.setClass( + SUKLegacy.KeyPair.self, + forClassName: "ECKeyPair" + ) + + dbConnection.read { transaction in + // MARK: --Identity keys + + registeredNumber = transaction.object( + 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: SUKLegacy.identityKeyStoreSeedKey, + inCollection: SUKLegacy.identityKeyStoreCollection + ) as? String + + userEd25519SecretKeyHexString = transaction.object( + forKey: SUKLegacy.identityKeyStoreEd25519SecretKey, + inCollection: SUKLegacy.identityKeyStoreCollection + ) as? String + + userEd25519PublicKeyHexString = transaction.object( + forKey: SUKLegacy.identityKeyStoreEd25519PublicKey, + inCollection: SUKLegacy.identityKeyStoreCollection + ) as? String + + userX25519KeyPair = transaction.object( + forKey: SUKLegacy.identityKeyStoreIdentityKey, + inCollection: SUKLegacy.identityKeyStoreCollection + ) as? SUKLegacy.KeyPair + } + + // 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: 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 + // case and we don't need to throw + if try Identity.fetchCount(db) == Identity.Variant.allCases.count { + return + } + + throw StorageError.migrationFailed + } + + // MARK: - Insert into GRDB + + try autoreleasepool { + // MARK: --Identity keys + + 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) + } + + 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 new file mode 100644 index 000000000..cc9749ce8 --- /dev/null +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -0,0 +1,166 @@ +// 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, CaseIterable, DatabaseValueConvertible { + case seed + case ed25519SecretKey + case ed25519PublicKey + case x25519PrivateKey + case x25519PublicKey + } + + public var id: Variant { variant } + + let variant: Variant + let data: Data + + // MARK: - Initialization + + public init( + variant: Variant, + data: Data + ) { + self.variant = variant + self.data = data + } +} + +// MARK: - Convenience + +extension ECKeyPair { + func toData() -> Data { + var targetValue: ECKeyPair = self + + return Data(bytes: &targetValue, count: MemoryLayout.size(ofValue: targetValue)) + } +} + +// MARK: - GRDB Interactions + +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) { + 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) + try Identity(variant: .x25519PrivateKey, data: x25519KeyPair.privateKey).save(db) + try Identity(variant: .x25519PublicKey, data: x25519KeyPair.publicKey).save(db) + } + } + + static func userExists(_ db: Database? = nil) -> Bool { + return (fetchUserKeyPair(db) != nil) + } + + static func fetchUserPublicKey(_ db: Database? = nil) -> Data? { + guard let db: Database = db else { + return Storage.shared.read { db in fetchUserPublicKey(db) } + } + + return try? Identity.fetchOne(db, id: .x25519PublicKey)?.data + } + + static func fetchUserPrivateKey(_ db: Database? = nil) -> Data? { + guard let db: Database = db else { + return Storage.shared.read { db in fetchUserPrivateKey(db) } + } + + return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data + } + + static func fetchUserKeyPair(_ db: Database? = nil) -> Box.KeyPair? { + guard let db: Database = db else { + return Storage.shared.read { db in fetchUserKeyPair(db) } + } + guard + let publicKey: Data = fetchUserPublicKey(db), + let privateKey: Data = fetchUserPrivateKey(db) + else { return nil } + + return Box.KeyPair( + publicKey: publicKey.bytes, + secretKey: privateKey.bytes + ) + } + + static func fetchUserEd25519KeyPair(_ db: Database? = nil) -> Box.KeyPair? { + guard let db: Database = db else { + return Storage.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? { + return Storage.shared.read { db in + guard let data: Data = try? Identity.fetchOne(db, id: .seed)?.data else { + return nil + } + + return data.toHexString() + } + } +} + +// 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 Storage.shared + .read { db in Identity.userExists(db) } + .defaulting(to: false) + } +} diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift new file mode 100644 index 000000000..471df30c1 --- /dev/null +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -0,0 +1,379 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +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]) + 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 { + case id + case failureCount + case variant + case behaviour + case shouldBlock + case shouldSkipLaunchBecomeActive + case nextRunTimestamp + case threadId + case interactionId + case details + } + + 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 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 + 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 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 + /// + /// **Note:** This is a blocking job so it will run before any other jobs and prevent them from + /// running until it's complete + 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 + /// + /// **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 + /// 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, CaseIterable { + /// 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 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) and + /// may run again during the same session if `nextRunTimestamp` gets set + 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 + + /// How the job should behave + public let behaviour: Behaviour + + /// When the app starts this flag controls whether the job should prevent other jobs from starting until after it completes + /// + /// **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 + + /// 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? + + /// 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.jobsThisJobDependsOn) + } + + /// 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.jobsThatDependOnThisJob) + } + + // MARK: - Initialization + + fileprivate init( + id: Int64?, + failureCount: UInt, + variant: Variant, + behaviour: Behaviour, + 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.shouldBlock = shouldBlock + self.shouldSkipLaunchBecomeActive = shouldSkipLaunchBecomeActive + self.nextRunTimestamp = nextRunTimestamp + self.threadId = threadId + self.interactionId = interactionId + self.details = details + } + + public init( + failureCount: UInt = 0, + variant: Variant, + behaviour: Behaviour = .runOnce, + 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.shouldBlock = shouldBlock + self.shouldSkipLaunchBecomeActive = shouldSkipLaunchBecomeActive + self.nextRunTimestamp = nextRunTimestamp + self.threadId = threadId + self.interactionId = interactionId + self.details = nil + } + + public init?( + failureCount: UInt = 0, + variant: Variant, + behaviour: Behaviour = .runOnce, + 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, + let detailsData: Data = try? JSONEncoder().encode(details) + else { return nil } + + self.failureCount = failureCount + self.variant = variant + self.behaviour = behaviour + 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?) { + self.id = rowID + } +} + +// MARK: - GRDB Interactions + +extension 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 + [ + 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.recurringOnActive + ].contains(Job.Columns.behaviour) && + Job.Columns.nextRunTimestamp > 0 + ) + ) + .filter(variants.contains(Job.Columns.variant)) + .order(Job.Columns.nextRunTimestamp) + .order(Job.Columns.id) + + if excludeFutureJobs { + query = query.filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970) + } + + if !includeJobsWithDependencies { + query = query.having(Job.jobsThisJobDependsOn.isEmpty) + } + + return query + } +} + +// MARK: - Convenience + +public extension Job { + func with( + failureCount: UInt = 0, + nextRunTimestamp: TimeInterval + ) -> Job { + return Job( + id: self.id, + failureCount: failureCount, + variant: self.variant, + behaviour: self.behaviour, + shouldBlock: self.shouldBlock, + shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive, + nextRunTimestamp: nextRunTimestamp, + threadId: self.threadId, + interactionId: self.interactionId, + details: self.details + ) + } + + func with(details: T) -> Job? { + guard let detailsData: Data = try? JSONEncoder().encode(details) else { return nil } + + return Job( + id: self.id, + failureCount: self.failureCount, + variant: self.variant, + behaviour: self.behaviour, + shouldBlock: self.shouldBlock, + shouldSkipLaunchBecomeActive: self.shouldSkipLaunchBecomeActive, + 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 new file mode 100644 index 000000000..9cda7ceb1 --- /dev/null +++ b/SessionUtilitiesKit/Database/Models/JobDependencies.swift @@ -0,0 +1,49 @@ +// 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 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) + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case jobId + case dependantId + } + + /// The is the id of the main job + public let jobId: 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 + + 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/Models/Setting.swift b/SessionUtilitiesKit/Database/Models/Setting.swift new file mode 100644 index 000000000..c3060a746 --- /dev/null +++ b/SessionUtilitiesKit/Database/Models/Setting.swift @@ -0,0 +1,226 @@ +// 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 { "setting" } + + 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 { + // MARK: - Numeric 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? { + // 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 + } + } + + // 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 + +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) } + } + + 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 Storage { + 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 { + 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 { + // 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) } + } + + 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) } + } + + 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?.rawValue) } + } + + /// 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/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/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/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index a2e2dcbc5..65d515f4b 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -1,71 +1,414 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB import PromiseKit -import YapDatabase +import SignalCoreKit -// 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 } +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 - @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) + private static var sharedDatabaseDirectoryPath: String { "\(OWSFileSystem.appSharedDataDirectoryPath())/database" } + private static var databasePath: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)" } + private static var databasePathShm: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-shm" } + private static var databasePathWal: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-wal" } + + public static var isDatabasePasswordAccessible: Bool { + guard (try? getDatabaseCipherKeySpec()) != nil else { return false } + + return true } - - // 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(()) + + public static let shared: Storage = Storage() + public private(set) var isValid: Bool = false + public private(set) var hasCompletedMigrations: Bool = false + + private var dbWriter: DatabaseWriter? + private var migrator: DatabaseMigrator? + private var migrationProgressUpdater: Atomic<((String, CGFloat) -> ())>? + + // MARK: - Initialization + + 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(Storage.sharedDatabaseDirectoryPath) + OWSFileSystem.protectFileOrFolder(atPath: Storage.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 } - return promise + + // 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 = Storage.getOrGenerateDatabaseKeySpec() + tmpKeySpec.resetBytes(in: 0.. ())?, + onComplete: @escaping (Error?, Bool) -> () + ) { + guard isValid, let dbWriter: DatabaseWriter = dbWriter 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([]) + } - /// 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 + 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() + sortedMigrationInfo.forEach { migrationInfo in + migrationInfo.migrations.forEach { migration in + migrator.registerMigration(migrationInfo.identifier, migration: migration) + } + } + + return migrator + }() + + // Determine which migrations need to be performed and gather the relevant settings needed to + // inform the app of progress/states + 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 + 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.. () = { [weak self] db, error in + self?.hasCompletedMigrations = true + self?.migrationProgressUpdater = nil + SUKLegacy.clearLegacyDatabaseInstance() + + if let error = error { + SNLog("[Migration Error] Migration failed with error: \(error)") + } + + onComplete(error, needsConfigSync) + } + + // Note: The non-async migration should only be used for unit tests + guard async else { + do { try self.migrator?.migrate(dbWriter) } + catch { try? dbWriter.read { db in migrationCompleted(db, error) } } + return + } + + self.migrator?.asyncMigrate(dbWriter) { db, error in + migrationCompleted(db, error) + } + } + + 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 } + + Storage.shared.migrationProgressUpdater?.wrappedValue(target.key(with: migration), progress) + } + + // MARK: - Security + + private static func getDatabaseCipherKeySpec() throws -> Data { + return try SSKDefaultKeychainStorage.shared.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?) -> T? { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } + + return try? dbWriter.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) { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } + + dbWriter.asyncWrite( + updates, + completion: { db, result in + try? completion(db, result) + } + ) + } + + @discardableResult public func read(_ value: (Database) throws -> T?) -> T? { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } + + return try? dbWriter.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 { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return AnyDatabaseCancellable(cancel: {}) } + + return observation.start( + in: dbWriter, + scheduling: scheduler, + onError: onError, + onChange: onChange + ) + } + + public func addObserver(_ observer: TransactionObserver?) { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } + guard let observer: TransactionObserver = observer else { return } + + dbWriter.add(transactionObserver: observer) + } + + public func removeObserver(_ observer: TransactionObserver?) { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } + guard let observer: TransactionObserver = observer else { return } + + dbWriter.remove(transactionObserver: observer) + } +} + +// MARK: - Promise Extensions + +public extension Storage { + // FIXME: Would be good to replace these with Swift Combine + @discardableResult func read(_ value: (Database) throws -> Promise) -> Promise { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { + return Promise(error: StorageError.databaseInvalid) + } + + do { + return try dbWriter.read(value) + } + catch { + return Promise(error: error) + } + } + + @discardableResult func writeAsync(updates: @escaping (Database) throws -> Promise) -> Promise { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { + return Promise(error: StorageError.databaseInvalid) + } + + 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 } } diff --git a/SessionUtilitiesKit/Database/StorageError.swift b/SessionUtilitiesKit/Database/StorageError.swift new file mode 100644 index 000000000..0112fddb1 --- /dev/null +++ b/SessionUtilitiesKit/Database/StorageError.swift @@ -0,0 +1,19 @@ +// 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 + + case devRemigrationRequired +} 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/Database/Types/ColumnExpressible.swift b/SessionUtilitiesKit/Database/Types/ColumnExpressible.swift new file mode 100644 index 000000000..032d1d193 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/ColumnExpressible.swift @@ -0,0 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +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/Types/Migration.swift b/SessionUtilitiesKit/Database/Types/Migration.swift new file mode 100644 index 000000000..b26bf1b97 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/Migration.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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 new file mode 100644 index 000000000..bdc17323e --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -0,0 +1,1155 @@ +// 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 + public var pageInfo: Atomic + + private let observedTableChangeTypes: [String: PagedData.ObservedChanges] + 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 groupSQL: SQL? + private let orderSQL: SQL + private let dataQuery: ([Int64]) -> 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 + + public init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + groupSQL: SQL? = nil, + orderSQL: SQL, + dataQuery: @escaping ([Int64]) -> AdaptedFetchRequest>, + associatedRecords: [ErasedAssociatedRecord] = [], + 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") + + self.pagedTableName = pagedTable.databaseTableName + self.idColumnName = idColumn.name + 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 + .map { $0.settingPagedTableName(pagedTableName: pagedTable.databaseTableName) } + 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 + .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() + } + + // 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 joinSQL: SQL? = self.joinSQL + 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, + joinSQL: joinSQL, + 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 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 !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] = directChanges + .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 changesToQuery: [PagedData.TrackedChange] = directChanges + .filter { $0.kind != .delete } + + 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 + } + + // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen + let itemIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( + db, + rowIds: changesToQuery.map { $0.rowId }, + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + let relatedChangeIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( + db, + rowIds: Array(pagedRowIdsForRelatedChanges), + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + 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) + 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 = indexInfo.contains(where: { info -> Bool in + info.rowIndex >= updatedPageInfo.pageOffset && ( + info.rowIndex < updatedPageInfo.currentCount || ( + updatedPageInfo.currentCount < updatedPageInfo.pageSize && + info.rowIndex <= (updatedPageInfo.pageOffset + updatedPageInfo.pageSize) + ) + ) + }) + + return (indexesAreSequential && hasOneValidIndex ? + indexInfo.map { $0.rowId } : + indexInfo + .filter { info -> Bool in + info.rowIndex >= updatedPageInfo.pageOffset && ( + info.rowIndex < updatedPageInfo.currentCount || ( + updatedPageInfo.currentCount < updatedPageInfo.pageSize && + info.rowIndex <= (updatedPageInfo.pageOffset + updatedPageInfo.pageSize) + ) + ) + } + .map { info -> Int64 in info.rowId } + ) + } + 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) + updatedPageInfo = PagedData.PageInfo( + pageSize: updatedPageInfo.pageSize, + pageOffset: (updatedPageInfo.pageOffset + countBefore), + currentCount: updatedPageInfo.currentCount, + 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 !validChangeRowIds.isEmpty || !validRelatedChangeRowIds.isEmpty else { + updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) + return + } + + // Fetch the inserted/updated rows + let targetRowIds: [Int64] = Array((validChangeRowIds + validRelatedChangeRowIds).asSet()) + let updatedItems: [T] = (try? dataQuery(targetRowIds) + .fetchAll(db)) + .defaulting(to: []) + + // Process the upserted data + updatedDataCache = updatedDataCache.upserting(items: updatedItems) + + // 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 + ) + + 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 groupSQL: SQL? = self.groupSQL + let orderSQL: SQL = self.orderSQL + let dataQuery: ([Int64]) -> AdaptedFetchRequest> = self.dataQuery + + let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo)? = Storage.shared.read { [weak self] db in + 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): + // 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 max(0, (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 (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), + cacheCurrentEndIndex, + currentPageInfo.pageOffset + ) + + case .reloadCurrent: + return ( + currentPageInfo.currentCount, + currentPageInfo.pageOffset, + 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 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, + 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 } + } + self.isLoadingMoreData.mutate { $0 = false } + 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() + } + + public func reload() { + self.load(.reloadCurrent) + } +} + +// MARK: - Convenience + +public extension PagedDatabaseObserver { + convenience init( + pagedTable: ObservedTable.Type, + pageSize: Int, + idColumn: ObservedTable.Columns, + observedChanges: [PagedData.ObservedChanges], + joinSQL: SQL? = nil, + filterSQL: SQL, + groupSQL: SQL? = nil, + orderSQL: SQL, + dataQuery: @escaping ([Int64]) -> SQLRequest, + associatedRecords: [ErasedAssociatedRecord] = [], + onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> () + ) { + self.init( + pagedTable: pagedTable, + pageSize: pageSize, + idColumn: idColumn, + observedChanges: observedChanges, + joinSQL: joinSQL, + filterSQL: filterSQL, + groupSQL: groupSQL, + orderSQL: orderSQL, + dataQuery: { rowIds in dataQuery(rowIds).adapted { _ in ScopeAdapter([:]) } }, + associatedRecords: associatedRecords, + onChangeUnsorted: onChangeUnsorted + ) + } + + 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 pagedTableName: String { get } + var observedChanges: [PagedData.ObservedChanges] { get } + var joinToPagedType: SQL { get } + + func settingPagedTableName(pagedTableName: String) -> Self + func tryUpdateForDatabaseCommit( + _ db: Database, + changes: Set, + joinSQL: SQL?, + 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 { + public static let autoLoadNextPageDelay: DispatchTimeInterval = .milliseconds(400) + + // 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) + case reloadCurrent + } + + 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 let joinToPagedType: SQL? + + public init( + table: T.Type, + events: [DatabaseEvent.Kind] = [.insert, .update, .delete], + columns: [T.Columns], + joinToPagedType: SQL? = nil + ) { + self.databaseTableName = table.databaseTableName + self.events = events + self.columns = columns.map { $0.name } + self.joinToPagedType = joinToPagedType + } + } + + // 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 + } + } + + fileprivate struct RowIndexInfo: Decodable, FetchableRecord { + let rowId: Int64 + let rowIndex: Int64 + } + + // 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, + tableName: String, + idColumn: String, + requiredJoinSQL: SQL? = nil, + orderSQL: SQL, + filterSQL: SQL + ) -> 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 ?? "") + 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 + ) -> [RowIndexInfo] { + guard !rowIds.isEmpty else { return [] } + + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let request: SQLRequest = """ + SELECT + data.rowId AS rowId, + (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 ?? "") + 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] { + guard !pagedTypeRowIds.isEmpty else { return [] } + + let tableNameLiteral: SQL = SQL(stringLiteral: tableName) + let pagedTableNameLiteral: SQL = SQL(stringLiteral: pagedTableName) + let request: SQLRequest = """ + SELECT \(tableNameLiteral).rowid AS rowid + FROM \(pagedTableNameLiteral) + \(joinToPagedType) + WHERE \(pagedTableNameLiteral).rowId IN \(pagedTypeRowIds) + """ + + 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 + +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 + + 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 + } + + public 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 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 + ) -> 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 pagedRowIds: [Int64] = PagedData.pagedRowIdsForRelatedRowIds( + db, + tableName: databaseTableName, + pagedTableName: pagedTableName, + relatedRowIds: rowIdsToQuery, + joinToPagedType: joinToPagedType + ) + + // 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: [PagedData.RowIndexInfo] = 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: { info -> Bool in + info.rowIndex >= pageInfo.pageOffset && ( + info.rowIndex < pageInfo.currentCount || ( + pageInfo.currentCount < pageInfo.pageSize && + info.rowIndex <= (pageInfo.pageOffset + pageInfo.pageSize) + ) + ) + }) + + // 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: rowIdsToQuery, + 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/Types/TargetMigrations.swift b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift new file mode 100644 index 000000000..8c3916b3a --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift @@ -0,0 +1,68 @@ +// 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 utilitiesKit + 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 func key(with migration: Migration.Type) -> String { + return "\(self.rawValue).\(migration.identifier)" + } + } + + public typealias MigrationSet = [Migration.Type] + + let identifier: Identifier + let migrations: [MigrationSet] + + // MARK: - Initialization + + public init( + 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 + } + + // 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/TypedTableAlias.swift b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift new file mode 100644 index 000000000..14dd0aacd --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/TypedTableAlias.swift @@ -0,0 +1,38 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public class TypedTableAlias where T: TableRecord, T: ColumnExpressible { + 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/Database/Types/TypedTableDefinition.swift b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift new file mode 100644 index 000000000..67ce68016 --- /dev/null +++ b/SessionUtilitiesKit/Database/Types/TypedTableDefinition.swift @@ -0,0 +1,44 @@ +// 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 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, + 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..09a6cb7a5 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -0,0 +1,22 @@ +// 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) + } + } + + 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/DatabaseMigrator+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift new file mode 100644 index 000000000..337dd805f --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB + +public extension DatabaseMigrator { + 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/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..79b934153 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/TableRecord+Utilities.swift @@ -0,0 +1,12 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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/General/AppContext.h b/SessionUtilitiesKit/General/AppContext.h index 77d6ee718..ef759328d 100755 --- a/SessionUtilitiesKit/General/AppContext.h +++ b/SessionUtilitiesKit/General/AppContext.h @@ -104,8 +104,6 @@ NSString *NSStringForUIApplicationState(UIApplicationState value); @property (atomic, readonly) NSDate *appLaunchTime; -- (id)keychainStorage; - - (NSString *)appDocumentDirectoryPath; - (NSString *)appSharedDataDirectoryPath; diff --git a/SessionUtilitiesKit/General/Array+Utilities.swift b/SessionUtilitiesKit/General/Array+Utilities.swift index 0b4d8b7fd..215e0e56f 100644 --- a/SessionUtilitiesKit/General/Array+Utilities.swift +++ b/SessionUtilitiesKit/General/Array+Utilities.swift @@ -1,11 +1,55 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -public extension Array where Element : CustomStringConvertible { +import Foundation +public extension Array where Element: CustomStringConvertible { var prettifiedDescription: String { return "[ " + map { $0.description }.joined(separator: ", ") + " ]" } } +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 + } + + 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 } + + 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 + } + + 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 { return Set(self) diff --git a/SessionUtilitiesKit/General/Atomic.swift b/SessionUtilitiesKit/General/Atomic.swift index 14baeed68..d97f438a1 100644 --- a/SessionUtilitiesKit/General/Atomic.swift +++ b/SessionUtilitiesKit/General/Atomic.swift @@ -1,7 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import Foundation // 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: @@ -22,17 +24,18 @@ public class Atomic { public var projectedValue: Atomic { return self } - + // MARK: - Initialization + public init(_ initialValue: Value) { self.value = initialValue } - - // MARK: - Functions - public func mutate(_ mutation: (inout Value) -> Void) { + // MARK: - Functions + + @discardableResult public func mutate(_ mutation: (inout Value) -> T) -> T { return queue.sync { - mutation(&value) + return mutation(&value) } } } diff --git a/SessionUtilitiesKit/General/Data+Trimming.swift b/SessionUtilitiesKit/General/Data+Trimming.swift deleted file mode 100644 index 7dfdd3667..000000000 --- a/SessionUtilitiesKit/General/Data+Trimming.swift +++ /dev/null @@ -1,18 +0,0 @@ - -public extension Data { - - func removing05PrefixIfNeeded() -> Data { - var result = self - if result.count == 33 && result.toHexString().hasPrefix("05") { result.removeFirst() } - return result - } -} - -@objc public extension NSData { - - @objc func removing05PrefixIfNeeded() -> NSData { - var result = self as Data - if result.count == 33 && result.toHexString().hasPrefix("05") { 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..7db8c81da --- /dev/null +++ b/SessionUtilitiesKit/General/Data+Utilities.swift @@ -0,0 +1,37 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Data { + + func removingIdPrefixIfNeeded() -> Data { + var result = self + if result.count == 33 && SessionId.Prefix(from: 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 && SessionId.Prefix(from: result.toHexString()) != nil { result.removeFirst() } + return result as NSData + } +} diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift new file mode 100644 index 000000000..5ac20c999 --- /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: Storage? + public var storage: Storage { + get { Dependencies.getValueSettingIfNull(&_storage) { Storage.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: Storage? = 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/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..f6fe58977 --- /dev/null +++ b/SessionUtilitiesKit/General/Dictionary+Utilities.swift @@ -0,0 +1,64 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +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: ", ") + " ]" + } + + func asArray() -> [(key: Key, value: Value)] { + return Array(self) + } +} + +public extension Dictionary.Values { + func asArray() -> [Value] { + return Array(self) + } +} + +// MARK: - Functional Convenience + +public extension Dictionary { + public subscript(_ key: Key?) -> 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 } + + 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] { + guard let key: Key = key else { return self } + + var updatedDictionary: [Key: Value] = self + updatedDictionary.removeValue(forKey: key) + + return updatedDictionary + } +} diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 7fb5bacdf..7fd3487e0 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -1,3 +1,37 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Curve25519Kit + +public protocol GeneralCacheType { + var encodedPublicKey: String? { get set } +} + +public enum General { + public class Cache: GeneralCacheType { + public var encodedPublicKey: String? = nil + } + + public static var cache: Atomic = Atomic(Cache()) +} + +public enum GeneralError: Error { + case keyGenerationFailed +} + +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) + + dependencies.generalCache.mutate { $0.encodedPublicKey = sessionId.hexString } + return sessionId.hexString + } + + return "" +} /// Does nothing, but is never inlined and thus evaluating its argument will never be optimized away. /// 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/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/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/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/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index 351c64cfc..8fbeee36c 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -1,30 +1,56 @@ 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 { + public enum Bool: Swift.String { case hasSyncedInitialConfiguration = "hasSyncedConfiguration" - case hasViewedSeed case hasSeenLinkPreviewSuggestion case hasSeenCallIPExposureWarning case hasSeenCallMissedTips case isUsingFullAPNs - case hasHiddenMessageRequests + case wasUnlinked + case isMainAppActive + case isCallOngoing } - public enum Date : Swift.String { + public enum Date: Swift.String { case lastConfigurationSync case lastDisplayNameUpdate case lastProfilePictureUpdate + case lastProfilePictureUpload case lastOpenGroupImageUpdate case lastOpen + case lastGarbageCollection } - public enum Double : Swift.String { + public enum Double: Swift.String { case lastDeviceTokenUpload = "lastDeviceTokenUploadTime" } - public enum Int : Swift.String { + public enum Int: Swift.String { case appMode case hardfork case softfork @@ -36,7 +62,12 @@ public enum SNUserDefaults { } public extension UserDefaults { - + @objc static var sharedLokiProject: UserDefaults? { + UserDefaults(suiteName: "group.com.loki-project.loki-messenger") + } +} + +public extension UserDefaultsType { subscript(bool: SNUserDefaults.Bool) -> Bool { get { return self.bool(forKey: bool.rawValue) } set { set(newValue, forKey: bool.rawValue) } diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift new file mode 100644 index 000000000..7e251876e --- /dev/null +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -0,0 +1,50 @@ +// 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 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 } + + 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: Bytes) { + self.prefix = type + self.publicKey = publicKey.map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/SessionUtilitiesKit/General/Set+Utilities.swift b/SessionUtilitiesKit/General/Set+Utilities.swift new file mode 100644 index 000000000..5fb2d416b --- /dev/null +++ b/SessionUtilitiesKit/General/Set+Utilities.swift @@ -0,0 +1,32 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension Set { + func inserting(_ value: Element?) -> Set { + guard let value: Element = value else { return self } + + var updatedSet: Set = self + updatedSet.insert(value) + + 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 } + + var updatedSet: Set = self + updatedSet.remove(value) + + return updatedSet + } +} diff --git a/SessionMessagingKit/Utilities/Sodium+Conversion.swift b/SessionUtilitiesKit/General/Sodium+Utilities.swift similarity index 92% rename from SessionMessagingKit/Utilities/Sodium+Conversion.swift rename to SessionUtilitiesKit/General/Sodium+Utilities.swift index c522bdf92..b9161af12 100644 --- a/SessionMessagingKit/Utilities/Sodium+Conversion.swift +++ b/SessionUtilitiesKit/General/Sodium+Utilities.swift @@ -1,5 +1,9 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import Clibsodium import Sodium +import Curve25519Kit extension Sign { @@ -17,7 +21,7 @@ extension Sign { &x25519PublicKey, ed25519PublicKey ) - + return x25519PublicKey } diff --git a/SessionUtilitiesKit/General/String+Localization.swift b/SessionUtilitiesKit/General/String+Localization.swift deleted file mode 100644 index 2468d1d25..000000000 --- a/SessionUtilitiesKit/General/String+Localization.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+Trimming.swift b/SessionUtilitiesKit/General/String+Trimming.swift index 997ab8e04..53ccc91ee 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 && SessionId.Prefix(from: 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 && SessionId.Prefix(from: result) != nil { result.removeFirst(2) } return result as NSString } } diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift new file mode 100644 index 000000000..28dc50ac2 --- /dev/null +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -0,0 +1,81 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +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: "") + owsAssertDebug(localizedString != self, "Key \"\(self)\" is not set in Localizable.strings") + + return localizedString + } + + func dataFromHex() -> Data? { + 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) + .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) + } + + 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).. 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/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/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/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 new file mode 100644 index 000000000..c84a11283 --- /dev/null +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -0,0 +1,1006 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SignalCoreKit + +public protocol JobExecutor { + /// 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 } + + /// 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, + queue: DispatchQueue, + success: @escaping (Job, Bool) -> (), + failure: @escaping (Job, Error?, Bool) -> (), + deferred: @escaping (Job) -> () + ) +} + +public final class JobRunner { + private static let blockingQueue: Atomic = Atomic( + JobQueue( + type: .blocking, + qos: .default, + 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() + + 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), + jobVariants.remove(.messageSend), + jobVariants.remove(.notifyPushServer), + jobVariants.remove(.sendReadReceipts) + ].compactMap { $0 } + ) + let messageReceiveQueue: JobQueue = JobQueue( + type: .messageReceive, + // 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) + ].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) + ) + + 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([:]) + fileprivate static var perSessionJobsCompleted: Atomic> = Atomic([]) + private static var hasCompletedInitialBecomeActive: Atomic = Atomic(false) + + // MARK: - Configuration + + public static func add(executor: JobExecutor.Type, for variant: Job.Variant) { + executorMap.mutate { $0[variant] = executor } + } + + // 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 { + SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job") + return + } + + 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() + } + } + + /// 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 + + 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? { + 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 nil + + 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 nil + } + + queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) + + // Start the job runner if needed + db.afterNextTransactionCommit { _ in + queues.wrappedValue[updatedJob.variant]?.start() + } + + return updatedJob + } + + 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]) = Storage.shared + .read { db in + let blockingJobs: [Job] = try Job + .filter( + [ + Job.Behaviour.recurringOnLaunch, + Job.Behaviour.runOnceNextLaunch + ].contains(Job.Columns.behaviour) + ) + .filter(Job.Columns.shouldBlock == 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.shouldBlock == false) + .order(Job.Columns.id) + .fetchAll(db) + + return (blockingJobs, nonblockingJobs) + } + .defaulting(to: ([], [])) + + guard !jobsToRun.blocking.isEmpty || !jobsToRun.nonBlocking.isEmpty else { return } + + // 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 hasCompletedInitialBecomeActive: Bool = JobRunner.hasCompletedInitialBecomeActive.wrappedValue + let jobsToRun: [Job] = Storage.shared + .read { db in + return try Job + .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) + .order(Job.Columns.id) + .fetchAll(db) + } + .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.isEmpty else { + if !blockingQueueIsRunning { + jobQueues.forEach { _, queue in queue.start() } + } + return + } + + // Add and start any non-blocking jobs (if there are no blocking jobs) + let jobsByVariant: [Job.Variant: [Job]] = jobsToRun.grouped(by: \.variant) + + jobQueues.forEach { variant, queue in + queue.appDidBecomeActive( + with: (jobsByVariant[variant] ?? []), + canStart: !blockingQueueIsRunning + ) + } + 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 } + + 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 } + + return targetQueue.hasPendingOrRunningJob(with: detailsData) + } + + // 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" + } + } + } + + 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 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) + /// + /// **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.fireTimestamp = max(1, (timestamp - Date().timeIntervalSince1970)) + trigger.timer = Timer.scheduledTimerOnMainThread( + withTimeInterval: trigger.fireTimestamp, + repeats: false, + block: { [weak queue] _ in + queue?.start() + } + ) + + return trigger + } + + func invalidate() { + // Need to do this to prevent a strong reference cycle + timer?.invalidate() + timer = nil + } + } + + private static let deferralLoopThreshold: Int = 3 + + private let type: QueueType + private let executionType: ExecutionType + 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: (self.executionType == .concurrent ? [.concurrent] : []), + 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([]) + private var detailsForCurrentlyRunningJobs: Atomic<[Int64: Data?]> = Atomic([:]) + private var deferLoopTracker: Atomic<[Int64: (count: Int, times: [TimeInterval])]> = Atomic([:]) + + fileprivate var hasPendingJobs: Bool { !queue.wrappedValue.isEmpty } + + // MARK: - Initialization + + 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 + 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) + } + + fileprivate func detailsForAllCurrentlyRunningJobs() -> [Int64: Data?] { + return detailsForCurrentlyRunningJobs.wrappedValue + } + + 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(force: Bool = false) { + // We only want the JobRunner to run in the main app + guard CurrentAppContext().isMainApp 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) + guard DispatchQueue.getSpecific(key: queueKey) == queueContext else { + internalQueue.async { [weak self] in + self?.start() + } + 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] = 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 + .fetchAll(db) + } + .defaulting(to: []) + + // Determine the number of jobs to run + var jobCount: Int = 0 + + queue.mutate { queue in + queue.append(contentsOf: jobsToRun) + jobCount = queue.count + } + + // 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 { + if jobIdsAlreadyRunning.isEmpty { + isRunning.mutate { $0 = false } + scheduleNextSoonestJob() + } + return + } + + // Run the first job in the queue + if !wasAlreadyRunning { + SNLog("[JobRunner] Starting \(queueContext) with (\(jobCount) job\(jobCount != 1 ? "s" : ""))") + } + 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 + self?.runNextJob() + } + return + } + guard let (nextJob, numJobsRemaining): (Job, Int) = queue.mutate({ queue in queue.popFirst().map { ($0, queue.count) } }) else { + // 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 + } + 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] \(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] \(queueContext) Unable to run \(nextJob.variant) job due to missing required interactionId") + handleJobFailed(nextJob, error: JobRunnerError.requiredInteractionIdMissing, permanentFailure: true) + 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 + } + + // Check if the next job has any dependencies + let dependencyInfo: (expectedCount: Int, jobs: [Job]) = Storage.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) + + 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(queue.wrappedValue.compactMap { $0.id }) + + // If there are dependencies which aren't in the queue we should just append them + guard !jobIdsNotInQueue.isEmpty else { + queue.mutate { queue in + queue.append( + contentsOf: dependencyInfo.jobs + .filter { jobIdsNotInQueue.contains($0.id ?? -1) } + ) + queue.append(nextJob) + } + handleJobDeferred(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) + } + } + + handleJobDeferred(nextJob) + return + } + + // 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 + // blocked by the JobRunner's queue becuase 'jobQueue' is Atomic + var numJobsRunning: Int = 0 + nextTrigger.mutate { trigger in + trigger?.invalidate() // Need to invalidate to prevent a memory leak + trigger = nil + } + jobsCurrentlyRunning.mutate { jobsCurrentlyRunning in + 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( + nextJob, + queue: internalQueue, + success: handleJobSucceeded, + 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() { + 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) + } + + // If there are no remaining jobs the trigger the 'onQueueDrained' callback and stop + guard let nextJobTimestamp: TimeInterval = nextJobTimestamp else { + if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { + 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 { + // 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(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 { 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 + + /// This function is called when a job succeeds + private func handleJobSucceeded(_ job: Job, shouldStop: Bool) { + switch job.behaviour { + case .runOnce, .runOnceNextLaunch: + 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 + .filter(JobDependencies.Columns.dependantId == job.id) + .deleteAll(db) + + _ = try job.delete(db) + } + + case .recurring where shouldStop == true: + 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 + .filter(JobDependencies.Columns.dependantId == job.id) + .deleteAll(db) + + _ = 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: + Storage.shared.write { db in + _ = try job + .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: + 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 + } + + // 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] = Storage.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) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } + 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 func handleJobFailed(_ job: Job, error: Error?, permanentFailure: Bool) { + 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) } + + internalQueue.async { [weak self] in + self?.runNextJob() + } + return + } + + // If this is the blocking queue and a "blocking" job failed then rerun it immediately + 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) } + queue.mutate { $0.insert(job, at: 0) } + + 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)) + + Storage.shared.write { db in + guard + !permanentFailure && ( + maxFailureCount < 0 || + job.failureCount + 1 < maxFailureCount + ) + 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 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 + } + + SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))") + + _ = try job + .with( + failureCount: (job.failureCount + 1), + 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 { + queue.mutate { queue in + queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } + } + } + } + + jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } + detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: job.id) } + 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 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 new file mode 100644 index 000000000..8d015095d --- /dev/null +++ b/SessionUtilitiesKit/JobRunner/JobRunnerError.swift @@ -0,0 +1,16 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum JobRunnerError: Error { + case generic + + case executorMissing + case requiredThreadIdMissing + case requiredInteractionIdMissing + + case missingRequiredDetails + case missingDependencies + + case possibleDeferralLoop +} 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/DataSource.h b/SessionUtilitiesKit/Media/DataSource.h index a999ed7ff..544800031 100755 --- a/SessionUtilitiesKit/Media/DataSource.h +++ b/SessionUtilitiesKit/Media/DataSource.h @@ -41,7 +41,7 @@ NS_ASSUME_NONNULL_BEGIN + (nullable DataSource *)dataSourceWithData:(NSData *)data utiType:(NSString *)utiType; -+ (nullable DataSource *)dataSourceWithOversizeText:(NSString *_Nullable)text; ++ (nullable DataSource *)dataSourceWithText:(NSString *_Nullable)text; + (DataSource *)dataSourceWithSyncMessageData:(NSData *)data; diff --git a/SessionUtilitiesKit/Media/DataSource.m b/SessionUtilitiesKit/Media/DataSource.m index 43b22c895..dad3ed801 100755 --- a/SessionUtilitiesKit/Media/DataSource.m +++ b/SessionUtilitiesKit/Media/DataSource.m @@ -134,14 +134,14 @@ NS_ASSUME_NONNULL_BEGIN return [self dataSourceWithData:data fileExtension:fileExtension]; } -+ (nullable DataSource *)dataSourceWithOversizeText:(NSString *_Nullable)text ++ (nullable DataSource *)dataSourceWithText:(NSString *_Nullable)text { if (!text) { return nil; } NSData *data = [text.filterStringForDisplay dataUsingEncoding:NSUTF8StringEncoding]; - return [self dataSourceWithData:data fileExtension:kOversizeTextAttachmentFileExtension]; + return [self dataSourceWithData:data fileExtension:kTextAttachmentFileExtension]; } + (DataSource *)dataSourceWithSyncMessageData:(NSData *)data 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/MIMETypeUtil.h b/SessionUtilitiesKit/Media/MIMETypeUtil.h index 6b09c6437..697e15e3f 100644 --- a/SessionUtilitiesKit/Media/MIMETypeUtil.h +++ b/SessionUtilitiesKit/Media/MIMETypeUtil.h @@ -12,11 +12,13 @@ extern NSString *const OWSMimeTypeImageTiff1; extern NSString *const OWSMimeTypeImageTiff2; extern NSString *const OWSMimeTypeImageBmp1; extern NSString *const OWSMimeTypeImageBmp2; -extern NSString *const OWSMimeTypeOversizeTextMessage; +extern NSString *const OWSMimeTypeImageWebp; +extern NSString *const OWSMimeTypeImageHeic; +extern NSString *const OWSMimeTypeImageHeif; extern NSString *const OWSMimeTypeUnknownForTests; extern NSString *const kOversizeTextAttachmentUTI; -extern NSString *const kOversizeTextAttachmentFileExtension; +extern NSString *const kTextAttachmentFileExtension; extern NSString *const kUnknownTestAttachmentUTI; extern NSString *const kSyncMessageFileExtension; @@ -37,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 d1aa2ce6c..469898125 100644 --- a/SessionUtilitiesKit/Media/MIMETypeUtil.m +++ b/SessionUtilitiesKit/Media/MIMETypeUtil.m @@ -19,13 +19,14 @@ 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 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"; -NSString *const kOversizeTextAttachmentUTI = @"org.whispersystems.oversize-text-attachment"; -NSString *const kOversizeTextAttachmentFileExtension = @"txt"; +NSString *const kTextAttachmentFileExtension = @"txt"; NSString *const kUnknownTestAttachmentUTI = @"org.whispersystems.unknown"; NSString *const kSyncMessageFileExtension = @"bin"; @@ -87,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; @@ -99,6 +101,7 @@ NSString *const kSyncMessageFileExtension = @"bin"; dispatch_once(&onceToken, ^{ result = @{ OWSMimeTypeImageGif : @"gif", + OWSMimeTypeImageWebp : @"image/webp", }; }); return result; @@ -177,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; @@ -189,6 +193,7 @@ NSString *const kSyncMessageFileExtension = @"bin"; dispatch_once(&onceToken, ^{ result = @{ @"gif" : OWSMimeTypeImageGif, + @"image/webp" : OWSMimeTypeImageWebp }; }); return result; @@ -409,12 +414,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. @@ -564,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; @@ -1394,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", @@ -1943,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 dde75950b..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,19 @@ 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) + (BOOL)ows_isValidImageAtPath:(NSString *)filePath @@ -46,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; } @@ -92,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; @@ -138,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. @@ -148,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; @@ -163,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; } @@ -204,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]); } } @@ -234,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 @@ -303,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. @@ -385,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 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/Media/Updatable.swift b/SessionUtilitiesKit/Media/Updatable.swift new file mode 100644 index 000000000..4a4a39495 --- /dev/null +++ b/SessionUtilitiesKit/Media/Updatable.swift @@ -0,0 +1,121 @@ +// 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 + } +} + +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/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 c4aca0f08..8624165de 100644 --- a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h +++ b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h @@ -5,19 +5,14 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[]; #import #import -#import #import -#import #import -#import #import #import #import #import #import #import -#import -#import #import #import diff --git a/SessionUtilitiesKit/Networking/HTTP.swift b/SessionUtilitiesKit/Networking/HTTP.swift index 5ce5e12ac..9e5946735 100644 --- a/SessionUtilitiesKit/Networking/HTTP.swift +++ b/SessionUtilitiesKit/Networking/HTTP.swift @@ -67,49 +67,63 @@ public enum HTTP { } } - // MARK: Verb - public enum Verb : String { + // MARK: - Verb + + public enum Verb: String, Codable { case get = "GET" case put = "PUT" case post = "POST" case delete = "DELETE" } - // MARK: Error - public enum Error : LocalizedError { + // MARK: - Error + + public enum Error: LocalizedError, Equatable { case generic - case httpRequestFailed(statusCode: UInt, json: JSON?) + case invalidURL case invalidJSON - + case parsingFailed + case invalidResponse + case maxFileSizeExceeded + case httpRequestFailed(statusCode: UInt, data: Data?) + case timeout + 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 .generic: return "An error occurred." + case .invalidURL: return "Invalid URL." + case .invalidJSON: return "Invalid JSON." + 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." } } } - // 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 { + 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 @@ -117,7 +131,7 @@ public enum HTTP { 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 (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 { @@ -126,32 +140,36 @@ 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, json: 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).") // 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) - 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" + 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, 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) + return seal.reject(Error.httpRequestFailed(statusCode: statusCode, data: data)) } + + seal.fulfill(data) } task.resume() return promise 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/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/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/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/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..ede1f4e36 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Optional+Utilities.swift @@ -0,0 +1,31 @@ +// 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) + } +} + +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/SessionUtilitiesKitTests/General/SessionIdSpec.swift b/SessionUtilitiesKitTests/General/SessionIdSpec.swift new file mode 100644 index 000000000..c3f22512a --- /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("0088672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(SessionId(.standard, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) + .to(equal("0588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(SessionId(.blinded, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) + .to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + } + } + + 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/CommonMockedExtensions.swift b/SharedTest/CommonMockedExtensions.swift new file mode 100644 index 000000000..bbc01747b --- /dev/null +++ b/SharedTest/CommonMockedExtensions.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import Curve25519Kit + +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 + ) +} + +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 new file mode 100644 index 000000000..74e92d38a --- /dev/null +++ b/SharedTest/Mock.swift @@ -0,0 +1,204 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +// MARK: - Mocked + +protocol Mocked { static var mockValue: Self { get } } + +func any() -> R { R.mockValue } +func any() -> R { unsafeBitCast(0, to: R.self) } +func any() -> [K: V] { [:] } +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 { + 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) + } + + @discardableResult internal func accept(funcName: String = #function, checkArgs: [Any?], actionArgs: [Any?]) -> Any? { + 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 when(_ callBlock: @escaping (inout T) throws -> R) -> MockFunctionBuilder { + let builder: MockFunctionBuilder = MockFunctionBuilder(callBlock, mockInit: type(of: self).init) + functionConsumer.functionBuilders.append(builder.build) + + return builder + } + + // MARK: - Convenience + + 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]: + if dict.isEmpty { return "[:]" } + + 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 + } + } +} + +// 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: (inout 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 (inout 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 { + 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") + } + + 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: Atomic<[String: [String]]> = Atomic([:]) + + 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.mutate { $0[functionName] = ($0[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..d4f820ec9 --- /dev/null +++ b/SharedTest/NimbleExtensions.swift @@ -0,0 +1,253 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Nimble +import SessionUtilitiesKit + +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 (inout 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, _): + 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 = "\(exclusive ? " exclusive " : "")call to '\(callInfo.functionName)'" + } + } + + 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 (inout 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.wrappedValue.keys) + + // 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 + } + } + + // 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.wrappedValue.keys) + + // 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( + caughtException: caughtException, + function: maybeFunction, + allFunctionsCalled: allFunctionsCalled, + desiredFunctionCalls: desiredFunctionCalls + ) +} diff --git a/SharedTest/TestConstants.swift b/SharedTest/TestConstants.swift new file mode 100644 index 000000000..1b9fe29f5 --- /dev/null +++ b/SharedTest/TestConstants.swift @@ -0,0 +1,14 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +enum TestConstants { + // 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" +} diff --git a/SignalUtilitiesKit/Configuration.swift b/SignalUtilitiesKit/Configuration.swift index 11234855e..8fe3899d8 100644 --- a/SignalUtilitiesKit/Configuration.swift +++ b/SignalUtilitiesKit/Configuration.swift @@ -1,14 +1,17 @@ -import SessionMessagingKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import SessionSnodeKit +import SessionMessagingKit -extension OWSPrimaryStorage : OWSPrimaryStorageProtocol { } - -@objc(SNConfiguration) -public final class Configuration : NSObject { - - @objc public static func performMainSetup() { - SNMessagingKit.configure(storage: Storage.shared) - SNSnodeKit.configure(storage: Storage.shared) - SNUtilitiesKit.configure(owsPrimaryStorage: OWSPrimaryStorage.shared(), maxFileSize: UInt(Double(FileServerAPIV2.maxFileSize) / FileServerAPIV2.fileSizeORMultiplier)) +public enum Configuration { + public static func performMainSetup() { + // Need to do this first to ensure the legacy database exists + SNUtilitiesKit.configure( + maxFileSize: UInt(Double(FileServerAPI.maxFileSize) / FileServerAPI.fileSizeORMultiplier) + ) + + SNMessagingKit.configure() + SNSnodeKit.configure() } } diff --git a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift deleted file mode 100644 index 518a4bc7e..000000000 --- a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -@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" - - let dbConnection: YapDatabaseConnection = primaryStorage.newDatabaseConnection() - - let blockedSessionIds: Set = Set(dbConnection.object( - forKey: kOWSBlockingManager_BlockedPhoneNumbersKey, - inCollection: kOWSBlockingManager_BlockListCollection - ) as? [String] ?? []) - - Storage.write( - with: { transaction in - Storage.shared.getAllContacts(with: transaction) - .filter { contact -> Bool in blockedSessionIds.contains(contact.sessionID) } - .forEach { contact in - contact.isBlocked = true - Storage.shared.setContact(contact, using: transaction) - } - - // 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 8dc9708fe..000000000 --- a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift +++ /dev/null @@ -1,33 +0,0 @@ - -@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: [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) { - contact.isTrusted = true - contacts.append(contact) - } - } - Storage.write(with: { transaction in - contacts.forEach { contact in - Storage.shared.setContact(contact, using: transaction) - } - 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 441d9fc00..000000000 --- a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift +++ /dev/null @@ -1,63 +0,0 @@ -@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) { - 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) { - 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) - } - - - Storage.write(with: { transaction in - contacts.forEach { contact in - Storage.shared.setContact(contact, using: transaction) - } - threads.forEach { thread in - thread.save(with: transaction) - } - 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 1765e463c..000000000 --- a/SignalUtilitiesKit/Database/Migrations/OpenGroupServerIdLookupMigration.swift +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -@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] = [] - - 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 } - - thread.enumerateInteractions(with: transaction) { interaction, _ in - guard let tsMessage: TSMessage = interaction 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/Database/Migrations/SOGSV4Migration.swift b/SignalUtilitiesKit/Database/Migrations/SOGSV4Migration.swift new file mode 100644 index 000000000..d73972429 --- /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 "005" + } + + 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(true, false) + }) + } +} 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 d6d98b638..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 ae9e317b5..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/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/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 04ce2726a..87593e7c3 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -8,12 +8,13 @@ import MediaPlayer import PromiseKit import SessionUIKit import CoreServices +import SessionMessagingKit -@objc public protocol AttachmentApprovalViewControllerDelegate: AnyObject { func attachmentApproval( _ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], + forThreadId threadId: String, messageText: String? ) @@ -24,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: - @@ -44,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 } @@ -54,6 +52,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? @@ -120,13 +119,14 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC notImplemented() } - @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 +154,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) @@ -244,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() } @@ -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 @@ -603,9 +582,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 ) @@ -760,7 +743,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) { @@ -786,24 +769,16 @@ 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 } -} - -// 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) } } @@ -812,7 +787,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/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/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index f03ff9472..503b28ad0 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 { @@ -19,6 +20,10 @@ class AddMoreRailItem: GalleryRailItem { return view } + + func isEqual(to other: GalleryRailItem?) -> Bool { + return (other is AddMoreRailItem) + } } class SignalAttachmentItem: Hashable { @@ -56,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/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/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 2907d0c64..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 @@ -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..bf8130c9e 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) } @@ -161,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 @@ -344,7 +346,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 +413,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 = { @@ -543,7 +545,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 5cea0fcd6..000000000 --- a/SignalUtilitiesKit/Media Viewing & Editing/MessageApprovalViewController.swift +++ /dev/null @@ -1,211 +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 - } - - let publicKey = contactThread.contactSessionID() - nameLabel.text = Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey - 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? { - let publicKey = contactThread.contactSessionID() - return Storage.shared.getContact(with: publicKey)?.displayName(for: .regular) ?? publicKey - } - - // 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/Media Viewing & Editing/OWSVideoPlayer.swift b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift index 581b861cc..c99513091 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift @@ -4,51 +4,40 @@ import Foundation import AVFoundation +import SessionMessagingKit -@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, 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 { @@ -68,7 +57,7 @@ public class OWSVideoPlayer: NSObject { 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:) @@ -81,6 +70,6 @@ public class OWSVideoPlayer: NSObject { @objc private func playerItemDidPlayToCompletion(_ notification: Notification) { self.delegate?.videoPlayerDidPlayToCompletion(self) - audioSession.endAudioActivity(self.audioActivity) + Environment.shared?.audioSession.endAudioActivity(self.audioActivity) } } diff --git a/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift b/SignalUtilitiesKit/Messaging/BlockListUIUtils.swift index d933bc53e..8f0126117 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 { @@ -9,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, let contact: Contact = Storage.shared.getContact(with: thread.contactSessionID()) else { + guard threadId != userPublicKey else { completionBlock?(false) return } - let displayName: String = (contact.displayName(for: .regular) ?? thread.contactSessionID()) + let displayName: String = Profile.displayName(id: threadId) 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) + Storage.shared.writeAsync( + updates: { db in + try Contact + .fetchOrCreate(db, id: threadId) + .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( @@ -64,13 +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) { - guard let contact: Contact = Storage.shared.getContact(with: thread.contactSessionID()) else { - completionBlock?(true) - return - } - - let displayName: String = (contact.displayName(for: .regular) ?? 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(), @@ -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) + Storage.shared.writeAsync( + updates: { db in + try Contact + .fetchOrCreate(db, id: threadId) + .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/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/FullTextSearcher.swift b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift deleted file mode 100644 index cfc2f53cb..000000000 --- a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift +++ /dev/null @@ -1,401 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -import Foundation - - -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.threadRecord.uniqueId == rhs.thread.threadRecord.uniqueId && - 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.fetch(for: getUserHexEncodedPublicKey(), using: 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.threadRecord.uniqueId == rhs.thread.threadRecord.uniqueId - } -} - -@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 { - let profileName = Storage.shared.getContact(with: recipientId)?.name - return "\(recipientId) \(profileName ?? "")" - } -} 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/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/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/Messaging/Sending & Receiving/MessageSender+Convenience.swift b/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift deleted file mode 100644 index 50f678b5c..000000000 --- a/SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift +++ /dev/null @@ -1,143 +0,0 @@ -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 - } - - public static func syncConfiguration(forceSyncNow: Bool = true) -> Promise { - let (promise, seal) = Promise.pending() - let destination: Message.Destination = Message.Destination.contact(publicKey: getUserHexEncodedPublicKey()) - - // 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() - } - 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() { - return syncConfiguration(forceSyncNow: true).retainUntilComplete() - } -} diff --git a/SignalUtilitiesKit/Messaging/ThreadViewModel.swift b/SignalUtilitiesKit/Messaging/ThreadViewModel.swift deleted file mode 100644 index 12bcfd560..000000000 --- a/SignalUtilitiesKit/Messaging/ThreadViewModel.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation - -@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 - - 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 - } - - 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) - } -} diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index ccc28cb8a..f76a0cba6 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -7,44 +7,23 @@ 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 #import -#import -#import #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/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift index 81e2d664b..feac3ea3f 100644 --- a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift +++ b/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift @@ -1,17 +1,42 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUtilitiesKit @objc(LKIdenticon) -public final class Identicon : NSObject { +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) - var content = text - if content.count > 2 && content.hasPrefix("05") { + + 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) } + let initials: String = content .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 ? @@ -19,8 +44,13 @@ 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) } + let result = renderer.image { layer.render(in: $0.cgContext) } + + placeholderCache.mutate { $0.setObject(result, forKey: cacheKey as NSString) } + + return result } } diff --git a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift index e60c6fe5e..5dd308a11 100644 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ b/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift @@ -1,24 +1,29 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import YYImage 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 - @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() @@ -34,119 +39,165 @@ 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 - @objc(updateForContact:) - public func update(for publicKey: String) { - openGroupProfilePicture = nil - self.publicKey = publicKey - additionalPublicKey = nil - useFallbackPicture = false - update() + // 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 viewModel: SessionThreadViewModel = Storage.shared.read({ db -> SessionThreadViewModel? in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + return try SessionThreadViewModel + .conversationSettingsProfileQuery(threadId: threadId, userPublicKey: userPublicKey) + .fetchOne(db) + }) + else { return } + + update( + 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 + ), + showMultiAvatarForClosedGroup: true + ) } - @objc(updateForThread:) - public func update(for thread: TSThread) { - 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 - } - publicKey = randomUsers.count >= 1 ? randomUsers[0] : "" - additionalPublicKey = randomUsers.count >= 2 ? randomUsers[1] : "" - useFallbackPicture = false - } - update() - } else { // A one-to-one chat - let thread = thread as! TSContactThread - update(for: thread.contactSessionID()) - } - } - - @objc public func update() { + public func update( + publicKey: String = "", + profile: Profile? = nil, + additionalProfile: Profile? = nil, + threadVariant: SessionThread.Variant, + openGroupProfilePicture: UIImage? = nil, + useFallbackPicture: Bool = false, + showMultiAvatarForClosedGroup: Bool = false + ) { AssertIsOnMainThread() - func getProfilePicture(of size: CGFloat, for publicKey: String) -> UIImage? { - guard !publicKey.isEmpty else { return nil } - if let profilePicture = OWSProfileManager.shared().profileAvatar(forRecipientId: 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) + guard !useFallbackPicture else { + switch self.size { + case Values.smallProfilePictureSize.. (image: UIImage, isTappable: Bool) { + if let profile: Profile = profile, let profileData: Data = ProfileManager.profileAvatar(profile: profile), let image: YYImage = YYImage(data: profileData) { + return (image, 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 + + 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 + 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 = .scaleAspectFill + imageView.backgroundColor = Colors.unimportant + imageView.layer.cornerRadius = (targetSize / 2) + additionalImageView.layer.cornerRadius = (targetSize / 2) } - // MARK: Convenience - private func getImageView() -> UIImageView { - let result = UIImageView() + // MARK: - Convenience + + private func getImageView() -> YYAnimatedImageView { + let result = YYAnimatedImageView() result.layer.masksToBounds = true result.backgroundColor = Colors.unimportant - result.contentMode = .scaleAspectFit + result.contentMode = .scaleAspectFill + return result } @objc public func getProfilePicture() -> UIImage? { - return hasTappableProfilePicture ? imageView.image : nil + return (hasTappableProfilePicture ? imageView.image : nil) } } diff --git a/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift b/SignalUtilitiesKit/Screen Lock/OWSScreenLock.swift index b6c0002c5..306bac424 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 Storage.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) + Storage.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 Storage.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) + Storage.shared.writeAsync( + updates: { db in db[.screenLockTimeoutSeconds] = value }, + completion: { _, _ in + NotificationCenter.default.postNotificationNameAsync(OWSScreenLock.ScreenLockDidChange, object: nil) + } + ) } // MARK: - Methods @@ -140,8 +118,7 @@ import LocalAuthentication 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 @@ -162,7 +139,7 @@ import LocalAuthentication switch outcome { case .success: owsFailDebug("local authentication unexpected success") - completion(.failure(error:defaultErrorDescription)) + completion(.failure(error: defaultErrorDescription)) case .cancel, .failure, .unexpectedFailure: completion(outcome) } @@ -196,58 +173,49 @@ import LocalAuthentication 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", - 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 - } } 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: "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: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET".localized()) + case .touchIDNotAvailable: + Logger.error("local authentication error: touchIDNotAvailable.") + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_AVAILABLE".localized()) + case .touchIDNotEnrolled: + Logger.error("local authentication error: touchIDNotEnrolled.") + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_NOT_ENROLLED".localized()) + case .touchIDLockout: + Logger.error("local authentication error: touchIDLockout.") + return .failure(error: "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_LOCKOUT".localized()) + case .invalidContext: + owsFailDebug("context not valid.") + return .unexpectedFailure(error:defaultErrorDescription) + case .notInteractive: + owsFailDebug("context not interactive.") + return .unexpectedFailure(error:defaultErrorDescription) } } + return .failure(error:defaultErrorDescription) } @@ -263,10 +231,7 @@ import LocalAuthentication // 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/ModalActivityIndicatorViewController.swift b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift index a38915a40..4f1c818b0 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) @@ -57,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/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/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 } diff --git a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift index 013602477..ef256f75e 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: class { - var railItems: [GalleryRailItem] { get } -} +// MARK: - GalleryRailItem -public protocol GalleryRailItem: class { +public protocol GalleryRailItem { func buildRailItemView() -> UIView + func isEqual(to other: GalleryRailItem?) -> Bool } -protocol GalleryRailCellViewDelegate: class { +// 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,220 @@ 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(stackClippingView) + stackClippingView.addSubview(stackView) + + stackClippingView.autoPinEdgesToSuperviewEdges() + stackClippingView.autoMatch(.height, to: .height, of: scrollView) + stackView.autoPinEdgesToSuperviewEdges() } 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 + 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 + }() + + 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 + let newCellViews: [GalleryRailCellView] = buildCellViews( + items: album, + cellViewBuilder: cellViewBuilder + ) + + 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 + + self?.updateFocusedItem(focusedItem) + self?.stackView.layoutIfNeeded() + self?.isHidden = false + + updatedOldFrame = (self?.stackView.frame) + .defaulting(to: oldFrame) + self?.stackView.frame = updatedOldFrame.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 } - 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) + animateOut(self.stackView.frame, layoutNewItems, animateIn) } - // 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 +315,43 @@ 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() + self.stackView.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/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/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 8ebbbbf69..000000000 --- a/SignalUtilitiesKit/To Do/ContactCellView.m +++ /dev/null @@ -1,300 +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 { - SNContactContext context = [SNContact contextForThread:self.thread]; - self.nameLabel.text = [[LKStorage.shared getContactWithSessionID:recipientId] displayNameFor:context] ?: recipientId; - } - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(otherUsersProfileDidChange:) - name:kNSNotificationName_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:kNSNotificationName_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 -{ - 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 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[kNSNotificationKey_ProfileRecipientId]; - 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 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 d584604ec..000000000 --- a/SignalUtilitiesKit/To Do/OWSProfileManager.h +++ /dev/null @@ -1,65 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#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. -// -// 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 9208cca03..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/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 062c572d9..000000000 --- a/SignalUtilitiesKit/Utilities/AppSetup.m +++ /dev/null @@ -1,114 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "AppSetup.h" -#import "Environment.h" -#import "VersionMigrations.h" -#import -#import -#import -#import -#import -#import -#import -#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]; - - OWSProfileManager *profileManager = [[OWSProfileManager alloc] initWithPrimaryStorage:primaryStorage]; - OWSIdentityManager *identityManager = [[OWSIdentityManager alloc] initWithPrimaryStorage:primaryStorage]; - 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]; - - 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]]; - - [SSKEnvironment setShared:[[SSKEnvironment alloc] initWithProfileManager:profileManager - primaryStorage:primaryStorage - identityManager:identityManager - tsAccountManager:tsAccountManager - disappearingMessagesJob:disappearingMessagesJob - readReceiptManager:readReceiptManager - outgoingReceiptManager:outgoingReceiptManager - reachabilityManager:reachabilityManager - typingIndicators:typingIndicators]]; - - appSpecificSingletonBlock(); - - OWSAssertDebug(SSKEnvironment.shared.isComplete); - - [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:^() { - 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; - }]; - }); - }]; - }); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift new file mode 100644 index 000000000..dfc80f644 --- /dev/null +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -0,0 +1,84 @@ +// 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 (Error?, 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() + + // 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( + reachabilityManager: SSKReachabilityManagerImpl(), + audioSession: OWSAudioSession(), + proximityMonitoringManager: OWSProximityMonitoringManagerImpl(), + windowManager: OWSWindowManager(default: ()) + ) + appSpecificBlock() + + /// `performMainSetup` **MUST** run before `perform(migrations:)` + Configuration.performMainSetup() + + 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 (Error?, Bool) -> () + ) { + var backgroundTask: OWSBackgroundTask? = (backgroundTask ?? OWSBackgroundTask(labelStr: #function)) + + Storage.shared.perform( + migrations: [ + SNUtilitiesKit.migrations(), + SNSnodeKit.migrations(), + SNMessagingKit.migrations() + ], + onProgressUpdate: migrationProgressChanged, + onComplete: { error, needsConfigSync in + DispatchQueue.main.async { + migrationsCompletion(error, 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/CommonStrings.swift b/SignalUtilitiesKit/Utilities/CommonStrings.swift index 2d7199593..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 @@ -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") @@ -34,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") @@ -58,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/Differentiable+Utilities.swift b/SignalUtilitiesKit/Utilities/Differentiable+Utilities.swift new file mode 100644 index 000000000..ce6ff9c60 --- /dev/null +++ b/SignalUtilitiesKit/Utilities/Differentiable+Utilities.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import DifferenceKit + +extension Int: ContentEquatable { + public func isContentEqual(to source: Int) -> Bool { + return (self == source) + } +} 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 - } -} 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 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/NoopNotificationsManager.swift b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift index 86b11b0b9..76b31539a 100644 --- a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift +++ b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift @@ -1,19 +1,21 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -@objc -public class NoopNotificationsManager: NSObject, NotificationsProtocol { +import Foundation +import GRDB +import SessionMessagingKit - public func notifyUser(for incomingMessage: TSIncomingMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) { +public class NoopNotificationsManager: NotificationsProtocol { + public init() {} + + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) { owsFailDebug("") } - public func notifyUser(forIncomingCall callInfoMessage: TSInfoMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) { + public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { 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..958e41249 100644 --- a/SignalUtilitiesKit/Utilities/Notification+Loki.swift +++ b/SignalUtilitiesKit/Utilities/Notification+Loki.swift @@ -1,26 +1,17 @@ +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") } @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 } diff --git a/SignalUtilitiesKit/Utilities/OWSAlerts.swift b/SignalUtilitiesKit/Utilities/OWSAlerts.swift index 5db9ecf7d..f8cc99beb 100644 --- a/SignalUtilitiesKit/Utilities/OWSAlerts.swift +++ b/SignalUtilitiesKit/Utilities/OWSAlerts.swift @@ -3,27 +3,9 @@ // 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 { @@ -93,32 +75,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.")) - } } 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/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/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/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/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 c2b61eb2d..000000000 --- a/SignalUtilitiesKit/Utilities/ThreadUtil.m +++ /dev/null @@ -1,346 +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 -#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 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/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" + } } } diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index 7c64c12ab..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) } } @@ -137,23 +125,34 @@ public extension UIViewController { } func presentAlert(_ alert: UIAlertController, animated: Bool) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.presentAlert(alert, animated: animated) + } + return + } + setupForIPadIfNeeded(alert: alert) - self.present(alert, - animated: animated, - completion: { - alert.applyAccessibilityIdentifiers() - }) + + self.present(alert, animated: animated) { + alert.applyAccessibilityIdentifiers() + } } func presentAlert(_ alert: UIAlertController, completion: @escaping (() -> Void)) { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.presentAlert(alert, completion: completion) + } + return + } + setupForIPadIfNeeded(alert: alert) - self.present(alert, - animated: true, - completion: { - alert.applyAccessibilityIdentifiers() - - completion() - }) + + self.present(alert, animated: true) { + alert.applyAccessibilityIdentifiers() + completion() + } } private func setupForIPadIfNeeded(alert: UIAlertController) { 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; 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 70a121039..000000000 --- a/SignalUtilitiesKit/Utilities/VersionMigrations.m +++ /dev/null @@ -1,139 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "VersionMigrations.h" -#import "OWSDatabaseMigrationRunner.h" -#import -#import -#import -#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 - Dependencies - -+ (TSAccountManager *)tsAccountManager -{ - OWSAssertDebug(SSKEnvironment.shared.tsAccountManager); - - return SSKEnvironment.shared.tsAccountManager; -} - -#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"] && [self.tsAccountManager isRegistered]) { - [self clearVideoCache]; - } - - if ([self isVersion:previousVersion atLeast:@"2.0.0" andLessThan:@"2.3.0"] && [self.tsAccountManager isRegistered]) { - [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"]; - }]; - 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); - } - } else { - OWSLogDebug(@"No bloom filter cache to remove."); - } -} - -@end - -NS_ASSUME_NONNULL_END