diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 000000000..d1f21a6d6 --- /dev/null +++ b/.drone.jsonnet @@ -0,0 +1,134 @@ +// Intentionally doing a depth of 2 as libSession-util has it's own submodules (and libLokinet likely will as well) +local clone_submodules = { + name: 'Clone Submodules', + commands: ['git fetch --tags', 'git submodule update --init --recursive --depth=2'] +}; + +// cmake options for static deps mirror +local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https://oxen.rocks/deps ' else ''); + +// Cocoapods +// +// Unfortunately Cocoapods has a dumb restriction which requires you to use UTF-8 for the +// 'LANG' env var so we need to work around the with https://github.com/CocoaPods/CocoaPods/issues/6333 +local install_cocoapods = { + name: 'Install CocoaPods', + commands: ['LANG=en_US.UTF-8 pod install'] +}; + +// Load from the cached CocoaPods directory (to speed up the build) +local load_cocoapods_cache = { + name: 'Load CocoaPods Cache', + commands: [ + ||| + while test -e /Users/drone/.cocoapods_cache.lock; do + sleep 1 + done + |||, + 'touch /Users/drone/.cocoapods_cache.lock', + ||| + if [[ -d /Users/drone/.cocoapods_cache ]]; then + cp -r /Users/drone/.cocoapods_cache ./Pods + fi + |||, + 'rm /Users/drone/.cocoapods_cache.lock' + ] +}; + +// Override the cached CocoaPods directory (to speed up the next build) +local update_cocoapods_cache = { + name: 'Update CocoaPods Cache', + commands: [ + ||| + while test -e /Users/drone/.cocoapods_cache.lock; do + sleep 1 + done + |||, + 'touch /Users/drone/.cocoapods_cache.lock', + ||| + if [[ -d ./Pods ]]; then + rm -rf /Users/drone/.cocoapods_cache + cp -r ./Pods /Users/drone/.cocoapods_cache + fi + |||, + 'rm /Users/drone/.cocoapods_cache.lock' + ] +}; + + +[ + // Unit tests + { + kind: 'pipeline', + type: 'exec', + name: 'Unit Tests', + platform: { os: 'darwin', arch: 'amd64' }, + steps: [ + clone_submodules, + load_cocoapods_cache, + install_cocoapods, + { + name: 'Run Unit Tests', + commands: [ + 'mkdir build', + 'NSUnbufferedIO=YES set -o pipefail && xcodebuild test -workspace Session.xcworkspace -scheme Session -destination "platform=iOS Simulator,name=iPhone 14" -destination "platform=iOS Simulator,name=iPhone 14 Pro Max" -parallel-testing-enabled YES -test-timeouts-enabled YES -maximum-test-execution-time-allowance 2 -collect-test-diagnostics never 2>&1 | ./Pods/xcbeautify/xcbeautify --is-ci --report junit --report-path ./build/reports --junit-report-filename junit2.xml' + ], + }, + update_cocoapods_cache + ], + }, + // Simulator build + { + kind: 'pipeline', + type: 'exec', + name: 'Simulator Build', + platform: { os: 'darwin', arch: 'amd64' }, + steps: [ + clone_submodules, + load_cocoapods_cache, + install_cocoapods, + { + name: 'Build', + commands: [ + 'mkdir build', + 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration "App Store Release" -sdk iphonesimulator -archivePath ./build/Session_sim.xcarchive -destination "generic/platform=iOS Simulator" | ./Pods/xcbeautify/xcbeautify --is-ci' + ], + }, + update_cocoapods_cache, + { + name: 'Upload artifacts', + environment: { SSH_KEY: { from_secret: 'SSH_KEY' } }, + commands: [ + './Scripts/drone-static-upload.sh' + ] + }, + ], + }, + // AppStore build (generate an archive to be signed later) + { + kind: 'pipeline', + type: 'exec', + name: 'AppStore Build', + platform: { os: 'darwin', arch: 'amd64' }, + steps: [ + clone_submodules, + load_cocoapods_cache, + install_cocoapods, + { + name: 'Build', + commands: [ + 'mkdir build', + 'xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration "App Store Release" -sdk iphoneos -archivePath ./build/Session.xcarchive -destination "generic/platform=iOS" -allowProvisioningUpdates' + ], + }, + update_cocoapods_cache, + { + name: 'Upload artifacts', + environment: { SSH_KEY: { from_secret: 'SSH_KEY' } }, + commands: [ + './Scripts/drone-static-upload.sh' + ] + }, + ], + }, +] \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..b8c1e3809 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "LibSession-Util"] + path = LibSession-Util + url = https://github.com/oxen-io/libsession-util.git diff --git a/LibSession-Util b/LibSession-Util new file mode 160000 index 000000000..d8f07fa92 --- /dev/null +++ b/LibSession-Util @@ -0,0 +1 @@ +Subproject commit d8f07fa92c12c5c2409774e03e03395d7847d1c2 diff --git a/Podfile b/Podfile index d69db9d91..201db8853 100644 --- a/Podfile +++ b/Podfile @@ -1,30 +1,31 @@ platform :ios, '13.0' -source 'https://github.com/CocoaPods/Specs.git' use_frameworks! inhibit_all_warnings! +install! 'cocoapods', :warn_for_unused_master_specs_repo => false + +# CI Dependencies +pod 'xcbeautify' + # Dependencies to be included in the app and all extensions/frameworks abstract_target 'GlobalDependencies' do - pod 'PromiseKit' - pod 'CryptoSwift' # FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod pod 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build' pod 'GRDB.swift/SQLCipher' + + # FIXME: Would be nice to migrate from CocoaPods to SwiftPackageManager (should allow us to speed up build time), haven't gone through all of the dependencies but currently unfortunately SQLCipher doesn't support SPM (for more info see: https://github.com/sqlcipher/sqlcipher/issues/371) pod 'SQLCipher', '~> 4.5.3' # 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' target 'Session' do - pod 'AFNetworking' pod 'Reachability' pod 'PureLayout', '~> 3.1.8' pod 'NVActivityIndicatorView' pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage' - pod 'ZXingObjC' pod 'DifferenceKit' target 'SessionTests' do @@ -45,7 +46,6 @@ abstract_target 'GlobalDependencies' do # Dependencies that are shared across a number of extensions/frameworks but not all abstract_target 'ExtendedDependencies' do - pod 'AFNetworking' pod 'PureLayout', '~> 3.1.8' target 'SessionShareExtension' do @@ -97,28 +97,13 @@ abstract_target 'GlobalDependencies' do target 'SessionUIKit' do pod 'GRDB.swift/SQLCipher' pod 'DifferenceKit' + pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage' end end # Actions to perform post-install post_install do |installer| - enable_whole_module_optimization_for_crypto_swift(installer) set_minimum_deployment_target(installer) - enable_fts5_support(installer) - - #FIXME: Remove this workaround once an official fix is released (hopefully Cocoapods 1.12.1) - xcode_14_3_workaround(installer) -end - -def enable_whole_module_optimization_for_crypto_swift(installer) - installer.pods_project.targets.each do |target| - if target.name.end_with? "CryptoSwift" - target.build_configurations.each do |config| - config.build_settings['GCC_OPTIMIZATION_LEVEL'] = 'fast' - config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-O' - end - end - end end def set_minimum_deployment_target(installer) @@ -128,22 +113,3 @@ def set_minimum_deployment_target(installer) end end end - -# This is to ensure we enable support for FastTextSearch5 (might not be enabled by default) -# For more info see https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#enabling-fts5-support -def enable_fts5_support(installer) - installer.pods_project.targets.select { |target| target.name == "GRDB.swift" }.each do |target| - target.build_configurations.each do |config| - config.build_settings['OTHER_SWIFT_FLAGS'] = "$(inherited) -D SQLITE_ENABLE_FTS5" - end - end -end - -# Workaround for Xcode 14.3: -# Sourced from https://github.com/flutter/flutter/issues/123852#issuecomment-1493232105 -def xcode_14_3_workaround(installer) - system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-frameworks.sh\'') - system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests-frameworks.sh\'') - system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks.sh\'') - system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks.sh\'') -end diff --git a/Podfile.lock b/Podfile.lock index 9f60bdddf..4a101f497 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,23 +1,7 @@ PODS: - - AFNetworking (4.0.1): - - AFNetworking/NSURLSession (= 4.0.1) - - AFNetworking/Reachability (= 4.0.1) - - AFNetworking/Security (= 4.0.1) - - AFNetworking/Serialization (= 4.0.1) - - AFNetworking/UIKit (= 4.0.1) - - AFNetworking/NSURLSession (4.0.1): - - AFNetworking/Reachability - - AFNetworking/Security - - AFNetworking/Serialization - - AFNetworking/Reachability (4.0.1) - - AFNetworking/Security (4.0.1) - - AFNetworking/Serialization (4.0.1) - - AFNetworking/UIKit (4.0.1): - - AFNetworking/NSURLSession - CocoaLumberjack (3.8.0): - CocoaLumberjack/Core (= 3.8.0) - CocoaLumberjack/Core (3.8.0) - - CryptoSwift (1.4.2) - Curve25519Kit (2.1.0): - CocoaLumberjack - SignalCoreKit @@ -43,15 +27,6 @@ PODS: - NVActivityIndicatorView/Base (= 5.1.1) - NVActivityIndicatorView/Base (5.1.1) - OpenSSL-Universal (1.1.1300) - - PromiseKit (6.15.3): - - PromiseKit/CorePromise (= 6.15.3) - - PromiseKit/Foundation (= 6.15.3) - - PromiseKit/UIKit (= 6.15.3) - - PromiseKit/CorePromise (6.15.3) - - PromiseKit/Foundation (6.15.3): - - PromiseKit/CorePromise - - PromiseKit/UIKit (6.15.3): - - PromiseKit/CorePromise - PureLayout (3.1.9) - Quick (5.0.1) - Reachability (3.2) @@ -59,7 +34,6 @@ PODS: - SignalCoreKit (1.0.0): - CocoaLumberjack - OpenSSL-Universal - - SocketRocket (0.5.1) - Sodium (0.9.1) - SQLCipher (4.5.3): - SQLCipher/standard (= 4.5.3) @@ -67,7 +41,8 @@ PODS: - SQLCipher/standard (4.5.3): - SQLCipher/common - SwiftProtobuf (1.5.0) - - WebRTC-lib (96.0.0) + - WebRTC-lib (114.0.0) + - xcbeautify (0.17.0) - YapDatabase/SQLCipher (3.1.1): - YapDatabase/SQLCipher/Core (= 3.1.1) - YapDatabase/SQLCipher/Extensions (= 3.1.1) @@ -134,54 +109,44 @@ PODS: - YYImage/libwebp (1.0.4): - libwebp - YYImage/Core - - ZXingObjC (3.6.5): - - ZXingObjC/All (= 3.6.5) - - ZXingObjC/All (3.6.5) DEPENDENCIES: - - AFNetworking - - CryptoSwift - 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 (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`) - SQLCipher (~> 4.5.3) - SwiftProtobuf (~> 1.5.0) - WebRTC-lib + - xcbeautify - YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`) - YYImage/libwebp (from `https://github.com/signalapp/YYImage`) - - ZXingObjC SPEC REPOS: https://github.com/CocoaPods/Specs.git: - - AFNetworking - CocoaLumberjack - - CryptoSwift - DifferenceKit - GRDB.swift - libwebp - Nimble - NVActivityIndicatorView - OpenSSL-Universal - - PromiseKit - PureLayout - Quick - Reachability - SAMKeychain - - SocketRocket - SQLCipher - SwiftProtobuf - WebRTC-lib - - ZXingObjC + trunk: + - xcbeautify EXTERNAL SOURCES: Curve25519Kit: @@ -217,9 +182,7 @@ CHECKOUT OPTIONS: :git: https://github.com/signalapp/YYImage SPEC CHECKSUMS: - AFNetworking: 3bd23d814e976cd148d7d44c3ab78017b744cd58 CocoaLumberjack: 78abfb691154e2a9df8ded4350d504ee19d90732 - CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca GRDB.swift: fe420b1af49ec519c7e96e07887ee44f5dfa2b78 @@ -227,21 +190,19 @@ SPEC CHECKSUMS: Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2 - PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5 PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88 Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179 Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d - SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531 Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae SQLCipher: 57fa9f863fa4a3ed9dd3c90ace52315db8c0fdca SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2 - WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb + WebRTC-lib: d83df8976fa608b980f1d85796b3de66d60a1953 + xcbeautify: 6e2f57af5c3a86d490376d5758030a8dcc201c1b YapDatabase: b418a4baa6906e8028748938f9159807fd039af4 YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 - ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: e9443a8235dbff1fc342aa9bf08bbc66923adf68 +PODFILE CHECKSUM: dd814a5a92577bb2a94dac6a1cc482f193721cdf -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/Scripts/DecryptExportedKey.swift b/Scripts/DecryptExportedKey.swift new file mode 100644 index 000000000..d17e6ef2e --- /dev/null +++ b/Scripts/DecryptExportedKey.swift @@ -0,0 +1,20 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import CryptoKit + +let arguments = CommandLine.arguments + +// First argument is the file name +if arguments.count == 3 { + let encryptedData = Data(base64Encoded: arguments[1].data(using: .utf8)!)! + let hash: SHA256.Digest = SHA256.hash(data: arguments[2].data(using: .utf8)!) + let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator())) + let sealedBox = try! ChaChaPoly.SealedBox(combined: encryptedData) + let decryptedData = try! ChaChaPoly.open(sealedBox, using: key) + + print(Array(decryptedData).map { String(format: "%02x", $0) }.joined()) +} +else { + print("Please provide the base64 encoded 'encrypted key' and plain text 'password' as arguments") +} diff --git a/Scripts/LintLocalizableStrings.swift b/Scripts/LintLocalizableStrings.swift index 3f0860735..910179348 100755 --- a/Scripts/LintLocalizableStrings.swift +++ b/Scripts/LintLocalizableStrings.swift @@ -1,11 +1,6 @@ #!/usr/bin/xcrun --sdk macosx swift -// -// ListLocalizableStrings.swift -// Archa -// -// Created by Morgan Pretty on 18/5/20. -// Copyright © 2020 Archa. All rights reserved. +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. // // This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference // is canges to the localized usage regex @@ -19,35 +14,46 @@ let currentPath = ( /// 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)") - } + guard + let enumerator: FileManager.DirectoryEnumerator = fileManager.enumerator( + at: URL(fileURLWithPath: currentPath), + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ), + let fileUrls: [URL] = enumerator.allObjects as? [URL] + else { fatalError("Could not locate files in path directory: \(currentPath)") } - return files + return fileUrls + .filter { + ((try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false) && // No directories + !$0.path.contains("build/") && // Exclude files under the build folder (CI) + !$0.path.contains("Pods/") && // Exclude files under the pods folder + !$0.path.contains(".xcassets") && // Exclude asset bundles + !$0.path.contains(".app/") && // Exclude files in the app build directories + !$0.path.contains(".appex/") && // Exclude files in the extension build directories + !$0.path.localizedCaseInsensitiveContains("tests/") && // Exclude files under test directories + !$0.path.localizedCaseInsensitiveContains("external/") && ( // Exclude files under external directories + // Only include relevant files + $0.path.hasSuffix("Localizable.strings") || + NSString(string: $0.path).pathExtension == "swift" || + NSString(string: $0.path).pathExtension == "m" + ) + } + .map { $0.path } }() /// 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 - } + return pathFiles.filter { $0.hasSuffix("Localizable.strings") } }() /// 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" - ) + $0.hasSuffix(".swift") || + $0.hasSuffix(".m") } }() @@ -56,7 +62,6 @@ var executableFiles: [String] = { /// - 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)") } @@ -109,8 +114,6 @@ func localizedStringsInCode() -> [LocalizationCodeFile] { /// /// - 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()) @@ -128,8 +131,6 @@ func validateMatchKeys(_ files: [LocalizationStringsFile]) { /// - 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") } @@ -150,8 +151,6 @@ func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: /// - 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") } @@ -174,14 +173,18 @@ protocol Pathable { struct LocalizationStringsFile: Pathable { let path: String let kv: [String: String] + let duplicates: [(key: String, path: String)] var keys: [String] { return Array(kv.keys) } init(path: String) { + let result = ContentParser.parse(path) + self.path = path - self.kv = ContentParser.parse(path) + self.kv = result.kv + self.duplicates = result.duplicates } /// Writes back to localizable file with sorted keys and removed whitespaces and new lines @@ -204,9 +207,7 @@ struct ContentParser { /// /// - 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) ------------") - + static func parse(_ path: String) -> (kv: [String: String], duplicates: [(key: String, path: String)]) { let content = contents(atPath: path) let trimmed = content .replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil) @@ -218,13 +219,18 @@ struct ContentParser { 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() + var duplicates: [(key: String, path: String)] = [] + let kv: [String: String] = zip(keys, values) + .reduce(into: [:]) { results, keyValue in + guard results[keyValue.0] == nil else { + duplicates.append((keyValue.0, path)) + return + } + + results[keyValue.0] = keyValue.1 } - results[keyValue.0] = keyValue.1 - } + + return (kv, duplicates) } } @@ -232,20 +238,27 @@ func printPretty(_ string: String) { print(string.replacingOccurrences(of: "\\", with: "")) } -let stringFiles = create() +// MARK: - Processing + +let stringFiles: [LocalizationStringsFile] = create() if !stringFiles.isEmpty { - print("------------ Found \(stringFiles.count) file(s) ------------") + print("------------ Found \(stringFiles.count) file(s) - checking for duplicate, extra, missing and dead keys ------------") + + stringFiles.forEach { file in + file.duplicates.forEach { key, path in + printPretty("error: Found duplicate key: \(key) in file: \(path)") + } + } - 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() + let codeFiles: [LocalizationCodeFile] = localizedStringsInCode() validateMissingKeys(codeFiles, localizationFiles: stringFiles) validateDeadKeys(codeFiles, localizationFiles: stringFiles) } -print("------------ SUCCESS ------------") +print("------------ Complete ------------") diff --git a/Scripts/ProtoWrappers.py b/Scripts/ProtoWrappers.py index d5af28fae..b346d1b1f 100755 --- a/Scripts/ProtoWrappers.py +++ b/Scripts/ProtoWrappers.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- import os @@ -202,10 +202,18 @@ class BaseContext(object): return 'UInt32' elif field.proto_type == 'fixed64': return 'UInt64' + elif field.proto_type == 'int64': + return 'Int64' + elif field.proto_type == 'int32': + return 'Int32' elif field.proto_type == 'bool': return 'Bool' elif field.proto_type == 'bytes': return 'Data' + elif field.proto_type == 'double': + return 'Double' + elif field.proto_type == 'float': + return 'Float' else: matching_context = self.context_for_proto_type(field) if matching_context is not None: @@ -236,7 +244,11 @@ class BaseContext(object): return field.proto_type in ('uint64', 'uint32', 'fixed64', - 'bool', ) + 'int64', + 'int32', + 'bool', + 'double', + 'float', ) def can_field_be_optional(self, field): if self.is_field_primitive(field): @@ -288,8 +300,16 @@ class BaseContext(object): return '0' elif field.proto_type == 'fixed64': return '0' + elif field.proto_type == 'int64': + return '0' + elif field.proto_type == 'int32': + return '0' elif field.proto_type == 'bool': return 'false' + elif field.proto_type == 'double': + return '0' + elif field.proto_type == 'float': + return '0' elif self.is_field_an_enum(field): # TODO: Assert that rules is empty. enum_context = self.context_for_proto_type(field) diff --git a/Scripts/build_libSession_util.sh b/Scripts/build_libSession_util.sh new file mode 100755 index 000000000..c32606503 --- /dev/null +++ b/Scripts/build_libSession_util.sh @@ -0,0 +1,261 @@ +#!/bin/bash + +# XCode will error during it's dependency graph construction (which happens before the build +# stage starts and any target "Run Script" phases are triggered) +# +# In order to avoid this error we need to build the framework before actually getting to the +# build stage so XCode is able to build the dependency graph +# +# XCode's Pre-action scripts don't output anything into XCode so the only way to emit a useful +# error is to **return a success status** and have the project detect and log the error itself +# then log it, stopping the build at that point +# +# The other step to get this to work properly is to ensure the framework in "Link Binary with +# Libraries" isn't using a relative directory, unfortunately there doesn't seem to be a good +# way to do this directly so we need to modify the '.pbxproj' file directly, updating the +# framework entry to have the following (on a single line): +# { +# isa = PBXFileReference; +# explicitFileType = wrapper.xcframework; +# includeInIndex = 0; +# path = "{FRAMEWORK NAME GOES HERE}"; +# sourceTree = BUILD_DIR; +# }; +# +# Note: We might one day be able to replace this with a local podspec if this GitHub feature +# request ever gets implemented: https://github.com/CocoaPods/CocoaPods/issues/8464 + +# Need to set the path or we won't find cmake +PATH=${PATH}:/usr/local/bin:/opt/homebrew/bin:/sbin/md5 + +exec 3>&1 # Save original stdout + +# Ensure the build directory exists (in case we need it before XCode creates it) +mkdir -p "${TARGET_BUILD_DIR}/libSessionUtil" + +# Remove any old build errors +rm -rf "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log" + +# Restore stdout and stderr and redirect it to the 'libsession_util_output.log' file +exec &> "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log" + +# Define a function to echo a message. +function echo_message() { + exec 1>&3 # Restore stdout + echo "$1" + exec >> "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log" # Redirect all output to the log file +} + +echo_message "info: Validating build requirements" + +set -x + +# Ensure the build directory exists (in case we need it before XCode creates it) +mkdir -p "${TARGET_BUILD_DIR}" + +if ! which cmake > /dev/null; then + echo_message "error: cmake is required to build, please install (can install via homebrew with 'brew install cmake')." + exit 0 +fi + +# Check if we have the `LibSession-Util` submodule checked out and if not (depending on the 'SHOULD_AUTO_INIT_SUBMODULES' argument) perform the checkout +if [ ! -d "${SRCROOT}/LibSession-Util" ] || [ ! -d "${SRCROOT}/LibSession-Util/src" ] || [ ! "$(ls -A "${SRCROOT}/LibSession-Util")" ]; then + echo_message "error: Need to fetch LibSession-Util submodule (git submodule update --init --recursive)." + exit 0 +else + are_submodules_valid() { + local PARENT_PATH=$1 + local RELATIVE_PATH=$2 + + # Change into the path to check for it's submodules + cd "${PARENT_PATH}" + local SUB_MODULE_PATHS=($(git config --file .gitmodules --get-regexp path | awk '{ print $2 }')) + + # If there are no submodules then return success based on whether the folder has any content + if [ ${#SUB_MODULE_PATHS[@]} -eq 0 ]; then + if [[ ! -z "$(ls -A "${PARENT_PATH}")" ]]; then + return 0 + else + return 1 + fi + fi + + # Loop through the child submodules and check if they are valid + for i in "${!SUB_MODULE_PATHS[@]}"; do + local CHILD_PATH="${SUB_MODULE_PATHS[$i]}" + + # If the child path doesn't exist then it's invalid + if [ ! -d "${PARENT_PATH}/${CHILD_PATH}" ]; then + echo_message "info: Submodule '${RELATIVE_PATH}/${CHILD_PATH}' doesn't exist." + return 1 + fi + + are_submodules_valid "${PARENT_PATH}/${CHILD_PATH}" "${RELATIVE_PATH}/${CHILD_PATH}" + local RESULT=$? + + if [ "${RESULT}" -eq 1 ]; then + echo_message "info: Submodule '${RELATIVE_PATH}/${CHILD_PATH}' is in an invalid state." + return 1 + fi + done + + return 0 + } + + # Validate the state of the submodules + are_submodules_valid "${SRCROOT}/LibSession-Util" "LibSession-Util" + + HAS_INVALID_SUBMODULE=$? + + if [ "${HAS_INVALID_SUBMODULE}" -eq 1 ]; then + echo_message "error: Submodules are in an invalid state, please delete 'LibSession-Util' and run 'git submodule update --init --recursive'." + exit 0 + fi +fi + +# Generate a hash of the libSession-util source files and check if they differ from the last hash +echo "info: Checking for changes to source" + +NEW_SOURCE_HASH=$(find "${SRCROOT}/LibSession-Util/src" -type f -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') +NEW_HEADER_HASH=$(find "${SRCROOT}/LibSession-Util/include" -type f -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}') + +if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_source_hash.log" ]; then + read -r OLD_SOURCE_HASH < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_source_hash.log" +fi + +if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_header_hash.log" ]; then + read -r OLD_HEADER_HASH < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_header_hash.log" +fi + +if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log" ]; then + read -r OLD_ARCHS < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log" +fi + +# If all of the hashes match, the archs match and there is a library file then we can just stop here +if [ "${NEW_SOURCE_HASH}" == "${OLD_SOURCE_HASH}" ] && [ "${NEW_HEADER_HASH}" == "${OLD_HEADER_HASH}" ] && [ "${ARCHS[*]}" == "${OLD_ARCHS}" ] && [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a" ]; then + echo_message "info: Build is up-to-date" + exit 0 +fi + +# If any of the above differ then we need to rebuild +echo_message "info: Build is not up-to-date - creating new build" + +# Import settings from XCode (defaulting values if not present) +VALID_SIM_ARCHS=(arm64 x86_64) +VALID_DEVICE_ARCHS=(arm64) +VALID_SIM_ARCH_PLATFORMS=(SIMULATORARM64 SIMULATOR64) +VALID_DEVICE_ARCH_PLATFORMS=(OS64) + +OUTPUT_DIR="${TARGET_BUILD_DIR}" +IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET} +ENABLE_BITCODE=${ENABLE_BITCODE} + +# Generate the target architectures we want to build for +TARGET_ARCHS=() +TARGET_PLATFORMS=() +TARGET_SIM_ARCHS=() +TARGET_DEVICE_ARCHS=() + +if [ -z $PLATFORM_NAME ] || [ $PLATFORM_NAME = "iphonesimulator" ]; then + for i in "${!VALID_SIM_ARCHS[@]}"; do + ARCH="${VALID_SIM_ARCHS[$i]}" + ARCH_PLATFORM="${VALID_SIM_ARCH_PLATFORMS[$i]}" + + if [[ " ${ARCHS[*]} " =~ " ${ARCH} " ]]; then + TARGET_ARCHS+=("sim-${ARCH}") + TARGET_PLATFORMS+=("${ARCH_PLATFORM}") + TARGET_SIM_ARCHS+=("sim-${ARCH}") + fi + done +fi + +if [ -z $PLATFORM_NAME ] || [ $PLATFORM_NAME = "iphoneos" ]; then + for i in "${!VALID_DEVICE_ARCHS[@]}"; do + ARCH="${VALID_DEVICE_ARCHS[$i]}" + ARCH_PLATFORM="${VALID_DEVICE_ARCH_PLATFORMS[$i]}" + + if [[ " ${ARCHS[*]} " =~ " ${ARCH} " ]]; then + TARGET_ARCHS+=("ios-${ARCH}") + TARGET_PLATFORMS+=("${ARCH_PLATFORM}") + TARGET_DEVICE_ARCHS+=("ios-${ARCH}") + fi + done +fi + +# Build the individual architectures +for i in "${!TARGET_ARCHS[@]}"; do + build="${TARGET_BUILD_DIR}/libSessionUtil/${TARGET_ARCHS[$i]}" + platform="${TARGET_PLATFORMS[$i]}" + echo_message "Building ${TARGET_ARCHS[$i]} for $platform in $build" + + cd "${SRCROOT}/LibSession-Util" + ./utils/static-bundle.sh "$build" "" \ + -DCMAKE_TOOLCHAIN_FILE="${SRCROOT}/LibSession-Util/external/ios-cmake/ios.toolchain.cmake" \ + -DPLATFORM=$platform \ + -DDEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET \ + -DENABLE_BITCODE=$ENABLE_BITCODE + + if [ $? -ne 0 ]; then + LAST_OUTPUT=$(tail -n 4 "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log" | head -n 1) + echo_message "error: $LAST_OUTPUT" + exit 1 + fi +done + +# Remove the old static library file +rm -rf "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a" +rm -rf "${TARGET_BUILD_DIR}/libSessionUtil/Headers" + +# If needed combine simulator builds into a multi-arch lib +if [ "${#TARGET_SIM_ARCHS[@]}" -eq "1" ]; then + # Single device build + cp "${TARGET_BUILD_DIR}/libSessionUtil/${TARGET_SIM_ARCHS[0]}/libsession-util.a" "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a" +elif [ "${#TARGET_SIM_ARCHS[@]}" -gt "1" ]; then + # Combine multiple device builds into a multi-arch lib + echo_message "info: Built multiple architectures, merging into single static library" + lipo -create "${TARGET_BUILD_DIR}/libSessionUtil"/sim-*/libsession-util.a -output "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a" +fi + +# If needed combine device builds into a multi-arch lib +if [ "${#TARGET_DEVICE_ARCHS[@]}" -eq "1" ]; then + cp "${TARGET_BUILD_DIR}/libSessionUtil/${TARGET_DEVICE_ARCHS[0]}/libsession-util.a" "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a" +elif [ "${#TARGET_DEVICE_ARCHS[@]}" -gt "1" ]; then + # Combine multiple device builds into a multi-arch lib + echo_message "info: Built multiple architectures, merging into single static library" + lipo -create "${TARGET_BUILD_DIR}/libSessionUtil"/ios-*/libsession-util.a -output "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a" +fi + +# Save the updated hashes to disk to prevent rebuilds when there were no changes +echo "${NEW_SOURCE_HASH}" > "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_source_hash.log" +echo "${NEW_HEADER_HASH}" > "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_header_hash.log" +echo "${ARCHS[*]}" > "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log" +echo_message "info: Build complete" + +# Copy the headers across +echo_message "info: Copy headers and prepare modulemap" +mkdir -p "${TARGET_BUILD_DIR}/libSessionUtil/Headers" +cp -r "${SRCROOT}/LibSession-Util/include/session" "${TARGET_BUILD_DIR}/libSessionUtil/Headers" + +# The 'module.modulemap' is needed for XCode to be able to find the headers +modmap="${TARGET_BUILD_DIR}/libSessionUtil/Headers/module.modulemap" +echo "module SessionUtil {" >"$modmap" +echo " module capi {" >>"$modmap" +for x in $(cd include && find session -name '*.h'); do + echo " header \"$x\"" >>"$modmap" +done +echo -e " export *\n }" >>"$modmap" +if false; then + # If we include the cpp headers like this then Xcode will try to load them as C headers (which + # of course breaks) and doesn't provide any way to only load the ones you need (because this is + # Apple land, why would anything useful be available?). So we include the headers in the + # archive but can't let xcode discover them because it will do it wrong. + echo -e "\n module cppapi {" >>"$modmap" + for x in $(cd include && find session -name '*.hpp'); do + echo " header \"$x\"" >>"$modmap" + done + echo -e " export *\n }" >>"$modmap" +fi +echo "}" >>"$modmap" + +# Output to XCode just so the output is good +echo_message "info: libSessionUtil Ready" \ No newline at end of file diff --git a/Scripts/drone-static-upload.sh b/Scripts/drone-static-upload.sh new file mode 100755 index 000000000..4fd13faa5 --- /dev/null +++ b/Scripts/drone-static-upload.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +# Script used with Drone CI to upload build artifacts (because specifying all this in +# .drone.jsonnet is too painful). + + + +set -o errexit + +if [ -z "$SSH_KEY" ]; then + echo -e "\n\n\n\e[31;1mUnable to upload artifact: SSH_KEY not set\e[0m" + # Just warn but don't fail, so that this doesn't trigger a build failure for untrusted builds + exit 0 +fi + +echo "$SSH_KEY" >ssh_key + +set -o xtrace # Don't start tracing until *after* we write the ssh key + +chmod 600 ssh_key + +if [ -n "$DRONE_TAG" ]; then + # For a tag build use something like `session-ios-v1.2.3` + base="session-ios-$DRONE_TAG" +else + # Otherwise build a length name from the datetime and commit hash, such as: + # session-ios-20200522T212342Z-04d7dcc54 + base="session-ios-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}" +fi + +mkdir -v "$base" + +# Copy over the build products +prod_path="build/Session.xcarchive" +sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app" + +mkdir build +echo "Test" > "build/test.txt" + +if [ ! -d $prod_path ]; then + cp -av $prod_path "$base" +else if [ ! -d $sim_path ]; then + cp -av $sim_path "$base" +else + echo "Expected a file to upload, found none" >&2 + exit 1 +fi + +# tar dat shiz up yo +archive="$base.tar.xz" +tar cJvf "$archive" "$base" + +upload_to="oxen.rocks/${DRONE_REPO// /_}/${DRONE_BRANCH// /_}" + +# sftp doesn't have any equivalent to mkdir -p, so we have to split the above up into a chain of +# -mkdir a/, -mkdir a/b/, -mkdir a/b/c/, ... commands. The leading `-` allows the command to fail +# without error. +upload_dirs=(${upload_to//\// }) +put_debug= +mkdirs= +dir_tmp="" +for p in "${upload_dirs[@]}"; do + dir_tmp="$dir_tmp$p/" + mkdirs="$mkdirs +-mkdir $dir_tmp" +done + +sftp -i ssh_key -b - -o StrictHostKeyChecking=off drone@oxen.rocks < /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; - }; - 0DAEB0CC30945175049E8D88 /* [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-SessionSnodeKit-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; - }; - 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 = ( - ); - 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-SessionUIKit-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; - }; - 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; - }; - 567FCF8CB93B411EE1FD4BBF /* [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-SessionTests-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; - }; - 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; - }; - A08B0675BD19884F61FF48D9 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks.sh\"\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 */ = { + 0E6C1748F41E48ED59563D96 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5135,8 +5054,339 @@ 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; }; + 18CDA58AE057F8C9AE71F46E /* [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; + }; + 19CD7B4EDC153293FB61CBA1 /* [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-SessionTests-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; + }; + 2014435DF351DF6C60122751 /* [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; + }; + 23B58F2A2BA9E3295EA451C1 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 351E727E03A8F141EA25FBF4 /* [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", + ); + 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; + }; + 446B0E16474DF9F15509BC64 /* [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; + }; + 49439A35FE57D3C0768A8127 /* [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; + }; + 55CE11E14880742A24ADC127 /* [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; + }; + 5CE8055024B876590AED6DEA /* [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; + }; + 77F55C879DAF28750120D343 /* [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-SessionSnodeKit-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; + }; + 90DF4725BB1271EBA2C66A12 /* [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; + }; + D5AFDC09857840D2D2631E2D /* [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-SessionUIKit-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; + }; + E0D19D723F633D7EE6163A84 /* [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; + }; + EDDFB3BFBD5E1378BD03AAAB /* [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; + }; + FD9BDDFF2A5D229B005F1EBC /* Build libSessionUtil if Needed */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build libSessionUtil if Needed"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Scripts/build_libSession_util.sh\"\n"; + showEnvVarsInLog = 0; + }; + FDD82C422A2085B900425F05 /* Add Commit Hash To Build Info Plist */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + $BUILT_PRODUCTS_DIR/$INFOPLIST_PATH, + $TARGET_BUILD_DIR/$INFOPLIST_PATH, + ); + name = "Add Commit Hash To Build Info Plist"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "INFO_PLIST=\"${TARGET_BUILD_DIR}\"/\"${INFOPLIST_PATH}\"\n\n# Query and save the value; suppress any error message, if key not found.\nvalue=$(/usr/libexec/PlistBuddy -c 'print :GitCommitHash' \"${INFO_PLIST}\" 2>/dev/null)\n\n# Check if value is empty\nif [ -z \"$value\" ] \nthen\n /usr/libexec/PlistBuddy -c \"Add :GitCommitHash string\" \"${INFO_PLIST}\"\nfi\n\n/usr/libexec/PlistBuddy -c \"Set :GitCommitHash `git rev-parse --short=7 HEAD`\" \"${INFO_PLIST}\"\n"; + showEnvVarsInLog = 0; + }; FDE7214D287E50820093DF33 /* Lint Localizable.strings */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -5163,7 +5413,7 @@ files = ( FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */, B817AD9A26436593009DF825 /* SimplifiedConversationCell.swift in Sources */, - C3ADC66126426688005F1414 /* ShareVC.swift in Sources */, + C3ADC66126426688005F1414 /* ShareNavController.swift in Sources */, 7BAF54D427ACCF01003D12F8 /* SAEScreenLockViewController.swift in Sources */, B817AD9C26436F73009DF825 /* ThreadPickerVC.swift in Sources */, 7BAF54D327ACCF01003D12F8 /* ShareAppExtensionContext.swift in Sources */, @@ -5195,13 +5445,16 @@ FD71165B28E6DDBC00B47552 /* StyledNavigationController.swift in Sources */, C331FFE32558FB0000070591 /* TabBar.swift in Sources */, FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */, + FDF848F129406A30007DCAE5 /* Format.swift in Sources */, FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */, FD37EA0128A60473003AE748 /* UIKit+Theme.swift in Sources */, - 7B2E985829AC227C001792D7 /* UIContextualAction+Theming.swift in Sources */, FD37E9CF28A1EB1B003AE748 /* Theme.swift in Sources */, C331FFB92558FA8D00070591 /* UIView+Constraints.swift in Sources */, + FD0B77B029B69A65009169BA /* TopBannerController.swift in Sources */, FD37E9F628A5F106003AE748 /* Configuration.swift in Sources */, + FDBB25E72988BBBE00F1508E /* UIContextualAction+Theming.swift in Sources */, C331FFE02558FB0000070591 /* SearchBar.swift in Sources */, + FD16AB5B2A1DD7CA0083D849 /* PlaceholderIcon.swift in Sources */, FD71162C28E1451400B47552 /* Position.swift in Sources */, FD52090328B4680F006098F6 /* RadioButton.swift in Sources */, C331FFE82558FB0000070591 /* TextView.swift in Sources */, @@ -5212,10 +5465,12 @@ FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */, C331FF9A2558FA6B00070591 /* Values.swift in Sources */, FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */, + FD16AB5F2A1DD98F0083D849 /* ProfilePictureView.swift in Sources */, C331FFE42558FB0000070591 /* SessionButton.swift in Sources */, C331FFE92558FB0000070591 /* Separator.swift in Sources */, FD71163228E2C42A00B47552 /* IconSize.swift in Sources */, C33100282559000A00070591 /* UIView+Utilities.swift in Sources */, + FDE6E99829F8E63A00F93C5D /* Accessibility.swift in Sources */, FD37E9CA28A1E4BD003AE748 /* Theme+ClassicLight.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -5226,21 +5481,16 @@ files = ( C38EF3C6255B6DE7007E1867 /* ImageEditorModel.swift in Sources */, C38EF3C3255B6DE7007E1867 /* ImageEditorTextItem.swift in Sources */, - C33FDC7D255A582000E217F9 /* OWSDispatch.m in Sources */, - C38EF247255B6D67007E1867 /* NSAttributedString+OWS.m in Sources */, C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */, C38EF3C5255B6DE7007E1867 /* OWSViewController+ImageEditor.swift in Sources */, - C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */, C38EF385255B6DD2007E1867 /* AttachmentTextToolbar.swift in Sources */, C33FDD23255A582000E217F9 /* FeatureFlags.swift in Sources */, FD71161E28D9772700B47552 /* UIViewController+OWS.swift in Sources */, C38EF389255B6DD2007E1867 /* AttachmentTextView.swift in Sources */, C38EF3FF255B6DF7007E1867 /* TappableView.swift in Sources */, C38EF3C2255B6DE7007E1867 /* ImageEditorPaletteView.swift in Sources */, - C38EF245255B6D67007E1867 /* UIFont+OWS.m in Sources */, C38EF36F255B6DCC007E1867 /* OWSViewController.m in Sources */, C38EF3FB255B6DF7007E1867 /* UIAlertController+OWS.swift in Sources */, - C33FDC53255A582000E217F9 /* OutageDetection.swift in Sources */, C38EF30C255B6DBF007E1867 /* ScreenLock.swift in Sources */, C38EF363255B6DCC007E1867 /* ModalActivityIndicatorViewController.swift in Sources */, C38EF38A255B6DD2007E1867 /* AttachmentCaptionToolbar.swift in Sources */, @@ -5251,18 +5501,15 @@ C3F0A530255C80BC007BE2A3 /* NoopNotificationsManager.swift in Sources */, C33FDD8D255A582000E217F9 /* OWSSignalAddress.swift in Sources */, C38EF388255B6DD2007E1867 /* AttachmentApprovalViewController.swift in Sources */, - C38EF2A7255B6D93007E1867 /* ProfilePictureView.swift in Sources */, C38EF22C255B6D5D007E1867 /* OWSVideoPlayer.swift in Sources */, C33FDC29255A581F00E217F9 /* ReachabilityManager.swift in Sources */, C38EF407255B6DF7007E1867 /* Toast.swift in Sources */, C38EF38C255B6DD2007E1867 /* ApprovalRailCellView.swift in Sources */, - C38EF2A6255B6D93007E1867 /* PlaceholderIcon.swift in Sources */, C33FDD92255A582000E217F9 /* SignalIOS.pb.swift in Sources */, C33FDC45255A581F00E217F9 /* AppVersion.m in Sources */, C38EF3C7255B6DE7007E1867 /* ImageEditorCanvasView.swift in Sources */, C38EF400255B6DF7007E1867 /* GalleryRailView.swift in Sources */, C38EF32E255B6DBF007E1867 /* ImageCache.swift in Sources */, - C38EF32F255B6DBF007E1867 /* OWSFormat.m in Sources */, FD87DD0428B8727D00AF0F98 /* Configuration.swift in Sources */, C38EF3BA255B6DE7007E1867 /* ImageEditorItem.swift in Sources */, C33FDD3A255A582000E217F9 /* Notification+Loki.swift in Sources */, @@ -5310,37 +5557,67 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - FD77289C284DDCE10018502F /* SnodePoolResponse.swift in Sources */, - FD09796927F6BEA700936362 /* SwarmSnode.swift in Sources */, + FDF8488B29405BF2007DCAE5 /* SSKDependencies.swift in Sources */, + FDF8488E29405C04007DCAE5 /* GetSnodePoolJob.swift in Sources */, + FDF848C329405C5A007DCAE5 /* DeleteMessagesRequest.swift in Sources */, + FDF8489129405C13007DCAE5 /* SnodeAPINamespace.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 */, + FDF848C529405C5B007DCAE5 /* GetSwarmRequest.swift in Sources */, + FDF848D729405C5B007DCAE5 /* SnodeBatchRequest.swift in Sources */, C3C2A5C0255385EE00C340D1 /* Snode.swift in Sources */, + FDF848D829405C5B007DCAE5 /* SwarmSnode.swift in Sources */, + FDF848CE29405C5B007DCAE5 /* UpdateExpiryAllRequest.swift in Sources */, + FDF848C229405C5A007DCAE5 /* OxenDaemonRPCRequest.swift in Sources */, + FDF848DC29405C5B007DCAE5 /* RevokeSubkeyRequest.swift in Sources */, + FD4324302999F0BC008A0213 /* ValidatableResponse.swift in Sources */, FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */, - C3C2A5C7255385EE00C340D1 /* SnodeAPI.swift in Sources */, - C3C2A5C6255385EE00C340D1 /* Notification+OnionRequestAPI.swift in Sources */, + FDF848EC29405E4F007DCAE5 /* OnionRequestAPI+Encryption.swift in Sources */, FD17D7AA27F41BF500122BE0 /* SnodeSet.swift in Sources */, + FDF848D029405C5B007DCAE5 /* UpdateExpiryResponse.swift in Sources */, + FDF848D329405C5B007DCAE5 /* UpdateExpiryAllResponse.swift in Sources */, + FDF848BC29405C5A007DCAE5 /* SnodeRecursiveResponse.swift in Sources */, + FDF848C029405C5A007DCAE5 /* ONSResolveResponse.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 */, + FDFC4D9A29F0C51500992FB6 /* String+Trimming.swift in Sources */, + FDF848C629405C5B007DCAE5 /* DeleteAllMessagesRequest.swift in Sources */, + FDF848D429405C5B007DCAE5 /* DeleteAllBeforeResponse.swift in Sources */, + FDF848D629405C5B007DCAE5 /* SnodeMessage.swift in Sources */, + FDF848D129405C5B007DCAE5 /* SnodeSwarmItem.swift in Sources */, + FDF848DD29405C5B007DCAE5 /* LegacySendMessageRequest.swift in Sources */, + FDF848BD29405C5A007DCAE5 /* GetMessagesRequest.swift in Sources */, + FDF848DB29405C5B007DCAE5 /* DeleteMessagesResponse.swift in Sources */, + FDF848E629405D6E007DCAE5 /* OnionRequestAPIDestination.swift in Sources */, + FDF848CC29405C5B007DCAE5 /* SnodeReceivedMessage.swift in Sources */, + FDF848C129405C5A007DCAE5 /* UpdateExpiryRequest.swift in Sources */, + FDF848C729405C5B007DCAE5 /* SendMessageResponse.swift in Sources */, + FD29598B2A43BB8100888A17 /* GetStatsResponse.swift in Sources */, + FDF848CA29405C5B007DCAE5 /* DeleteAllBeforeRequest.swift in Sources */, + FDF848D229405C5B007DCAE5 /* LegacyGetMessagesRequest.swift in Sources */, + FDF848CB29405C5B007DCAE5 /* SnodePoolResponse.swift in Sources */, + FDF848C429405C5A007DCAE5 /* RevokeSubkeyResponse.swift in Sources */, + FDF848E529405D6E007DCAE5 /* SnodeAPIError.swift in Sources */, + FDF848D529405C5B007DCAE5 /* DeleteAllMessagesResponse.swift in Sources */, + FDF848E329405D6E007DCAE5 /* OnionRequestAPIVersion.swift in Sources */, + FDF848BF29405C5A007DCAE5 /* SnodeResponse.swift in Sources */, C3C2A5E42553860B00C340D1 /* Data+Utilities.swift in Sources */, + FDF848D929405C5B007DCAE5 /* SnodeAuthenticatedRequestBody.swift in Sources */, + FDF848ED29405E4F007DCAE5 /* Notification+OnionRequestAPI.swift in Sources */, + FDF848CD29405C5B007DCAE5 /* GetNetworkTimestampResponse.swift in Sources */, + FDF848DA29405C5B007DCAE5 /* GetMessagesResponse.swift in Sources */, FD17D7A727F41AF000122BE0 /* SSKLegacy.swift in Sources */, - FDC438B327BB15B400C60D73 /* ResponseInfo.swift in Sources */, FD39353628F7C3390084DADA /* _004_FlagMessageHashAsDeletedOrInvalid.swift in Sources */, + FDF8489429405C1B007DCAE5 /* SnodeAPI.swift in Sources */, + FDF848C829405C5B007DCAE5 /* ONSResolveRequest.swift in Sources */, C3C2A5C2255385EE00C340D1 /* Configuration.swift in Sources */, - FD17D7D827F658E200122BE0 /* OnionRequestAPIDestination.swift in Sources */, + FDF848C929405C5B007DCAE5 /* SnodeRequest.swift in Sources */, + FDF848CF29405C5B007DCAE5 /* SendMessageRequest.swift in Sources */, FD6A7A6D2818C61500035AC1 /* _002_SetupStandardJobs.swift in Sources */, FD17D7B327F51E5B00122BE0 /* SSKSetting.swift in Sources */, + FDF848E429405D6E007DCAE5 /* SnodeAPIEndpoint.swift in Sources */, + FDF848E729405D6E007DCAE5 /* OnionRequestAPIError.swift in Sources */, + FDF848BE29405C5A007DCAE5 /* GetServiceNodesRequest.swift in Sources */, + FDF848EB29405E4F007DCAE5 /* OnionRequestAPI.swift in Sources */, FD17D7AE27F41C4300122BE0 /* SnodeReceivedMessageInfo.swift in Sources */, - C3C2A5C3255385EE00C340D1 /* OnionRequestAPI.swift in Sources */, - FD90040F2818AB6D00ABAAF6 /* GetSnodePoolJob.swift in Sources */, - FD17D7D427F6584600122BE0 /* OnionRequestAPIError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5348,25 +5625,24 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FDE658A129418C7900A33BC1 /* CryptoKit+Utilities.swift in Sources */, FDFD645927F26C6800808CA1 /* Array+Utilities.swift in Sources */, - 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */, - C32C5A47256DB8F0003C73A2 /* ECKeyPair+Hexadecimal.swift in Sources */, 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */, C32C5D83256DD5B6003C73A2 /* SSKKeychainStorage.swift in Sources */, + FDF8488329405A12007DCAE5 /* BatchResponse.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 */, + FDBB25E32988B13800F1508E /* _004_AddJobPriority.swift in Sources */, C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */, - FD09797B27FBB25900936362 /* Updatable.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 */, + FDF8487B29405906007DCAE5 /* HTTPHeader.swift in Sources */, FD9004122818ABDC00ABAAF6 /* Job.swift in Sources */, FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */, FD7115FE28C8202D00B47552 /* ReplaySubject.swift in Sources */, @@ -5375,21 +5651,22 @@ C32C5DD2256DD9E5003C73A2 /* LRUCache.swift in Sources */, FD71160228C8255900B47552 /* UIControl+Combine.swift in Sources */, FD9004152818B46300ABAAF6 /* JobRunner.swift in Sources */, - C3A7211A2558BCA10043A11F /* DiffieHellman.swift in Sources */, - C3A7225E2558C38D0043A11F /* Promise+Retaining.swift in Sources */, + FDF8487929405906007DCAE5 /* HTTPQueryParam.swift in Sources */, FD17D7CA27F546D900122BE0 /* _001_InitialSetupMigration.swift in Sources */, C3D9E4D12567777D0040E4F3 /* OWSMediaUtils.swift in Sources */, C3BBE0AA2554D4DE0050F1E3 /* Dictionary+Utilities.swift in Sources */, + FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */, FD37EA1128AB34B3003AE748 /* TypedTableAlteration.swift in Sources */, + FD30036E2A3AE26000B5A5FB /* CExceptionHelper.mm in Sources */, C3D9E4DA256778410040E4F3 /* UIImage+OWS.m in Sources */, C32C600F256E07F5003C73A2 /* NSUserDefaults+OWS.m in Sources */, + FDE658A329418E2F00A33BC1 /* KeyPair.swift in Sources */, FD37E9FF28A5F2CD003AE748 /* Configuration.swift in Sources */, FDB7400B28EB99A70094D718 /* TimeInterval+Utilities.swift 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 */, @@ -5398,11 +5675,12 @@ FD17D7BA27F51F2100122BE0 /* TargetMigrations.swift in Sources */, FD17D7C327F5204C00122BE0 /* Database+Utilities.swift in Sources */, FD17D7C527F5206300122BE0 /* ColumnDefinition+Utilities.swift in Sources */, + FD8ECF94293856AF00C0D1BB /* Randomness.swift in Sources */, FDC438CD27BC641200C60D73 /* Set+Utilities.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 */, + FDF848EF294067E4007DCAE5 /* URLResponse+Utilities.swift in Sources */, FD848B9A28442CE6000E298B /* StorageError.swift in Sources */, FD17D7B827F51ECA00122BE0 /* Migration.swift in Sources */, FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */, @@ -5410,15 +5688,15 @@ FD52090028AF6153006098F6 /* OWSBackgroundTask.m in Sources */, C32C5DDB256DD9FF003C73A2 /* ContentProxy.swift in Sources */, C3A71F892558BA9F0043A11F /* Mnemonic.swift in Sources */, - B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */, - C33FDEF8255A656D00E217F9 /* Promise+Delaying.swift in Sources */, + B8F5F58325EC94A6003BF8D4 /* Collection+Utilities.swift in Sources */, 7BD477A827EC39F5004E2822 /* Atomic.swift in Sources */, B8BC00C0257D90E30032E807 /* General.swift in Sources */, + FDF8488629405A61007DCAE5 /* Request.swift in Sources */, FD17D7A127F40D2500122BE0 /* Storage.swift in Sources */, FD1A94FB2900D1C2000D73D3 /* PersistableRecord+Utilities.swift in Sources */, FD5D201E27B0D87C00FEA984 /* SessionId.swift in Sources */, C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */, - C3BBE0A72554D4DE0050F1E3 /* Promise+Retrying.swift in Sources */, + FD8ECF922938552800C0D1BB /* Threading.swift in Sources */, B8856D7B256F14F4001CE70E /* UIView+OWS.m in Sources */, FDF22211281B5E0B000A4995 /* TableRecord+Utilities.swift in Sources */, B88FA7FB26114EA70049422F /* Hex.swift in Sources */, @@ -5427,25 +5705,30 @@ C3D9E4F4256778AF0040E4F3 /* NSData+Image.m in Sources */, FDF222092818D2B0000A4995 /* NSAttributedString+Utilities.swift in Sources */, C32C5E0C256DDAFA003C73A2 /* NSRegularExpression+SSK.swift in Sources */, + FD29598D2A43BC0B00888A17 /* Version.swift in Sources */, + FDF8487C29405906007DCAE5 /* HTTPMethod.swift in Sources */, + FDF8488429405A2B007DCAE5 /* RequestInfo.swift in Sources */, C3BBE0A92554D4DE0050F1E3 /* HTTP.swift in Sources */, FD71160028C8253500B47552 /* UIView+Combine.swift in Sources */, B8856D23256F116B001CE70E /* Weak.swift in Sources */, FD17D7CD27F546FF00122BE0 /* Setting.swift in Sources */, FD7115FC28C8155800B47552 /* Publisher+Utilities.swift in Sources */, C32C5A48256DB8F0003C73A2 /* BuildConfiguration.swift in Sources */, + FDF84881294059F5007DCAE5 /* ResponseInfo.swift in Sources */, FD17D7BF27F51F8200122BE0 /* ColumnExpressible.swift in Sources */, FD17D7E527F6A09900122BE0 /* Identity.swift in Sources */, FD9004142818AD0B00ABAAF6 /* _002_SetupStandardJobs.swift in Sources */, + FDF8487A29405906007DCAE5 /* HTTPError.swift in Sources */, + FDDF074429C3E3D000E5E8B5 /* FetchRequest+Utilities.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 */, + FDF8488929405B27007DCAE5 /* Data+Utilities.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 */, FD7115F828C8151C00B47552 /* DisposableBarButtonItem.swift in Sources */, FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, ); @@ -5455,8 +5738,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD8ECF7B29340FFD00C0D1BB /* SessionUtil.swift in Sources */, 7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */, - 7B89FF4629C016E300C4C708 /* _012_AddFTSIfNeeded.swift in Sources */, FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */, B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */, @@ -5468,12 +5751,14 @@ C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */, FDF40CDE2897A1BC006A0CC4 /* _004_RemoveLegacyYDB.swift in Sources */, FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */, + FD3003662A25D5B300B5A5FB /* ConfigMessageReceiveJob.swift in Sources */, 7B81682C28B72F480069F315 /* PendingChange.swift in Sources */, FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */, FD5C7309285007920029977D /* BlindedIdLookup.swift in Sources */, FD71161C28D194FB00B47552 /* MentionInfo.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, + FDF848F729414477007DCAE5 /* CurrentUserPoller.swift in Sources */, B8B558FF26C4E05E00693325 /* WebRTCSession+MessageHandling.swift in Sources */, C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */, FD09B7E7288670FD00ED0B66 /* Reaction.swift in Sources */, @@ -5496,43 +5781,46 @@ FD245C6A2850666F00B966DD /* FileServerAPI.swift in Sources */, FDC4386927B4E6B800C60D73 /* String+Utlities.swift in Sources */, FD716E6628502EE200C96BF4 /* CurrentCallProtocol.swift in Sources */, + FD8ECF9029381FC200C0D1BB /* SessionUtil+UserProfile.swift in Sources */, FD09B7E5288670BB00ED0B66 /* _008_EmojiReacts.swift in Sources */, + FDFF61D729F2600300F95FB0 /* Identity+Utilities.swift in Sources */, FDC4385F27B4C4A200C60D73 /* PinnedMessage.swift in Sources */, 7BFD1A8C2747150E00FB91B9 /* TurnServerInfo.swift in Sources */, - C3D9E3BF25676AD70040E4F3 /* (null) in Sources */, B8BF43BA26CC95FB007828D1 /* WebRTC+Utilities.swift in Sources */, 7B8D5FC428332600008324D9 /* VisibleMessage+Reaction.swift in Sources */, - C3BBE0B52554F0E10050F1E3 /* (null) in Sources */, FDC4386527B4DE7600C60D73 /* RoomPollInfo.swift in Sources */, FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */, FD37EA0F28AB3330003AE748 /* _006_FixHiddenModAdminSupport.swift in Sources */, + FD2B4AFD294688D000AB4848 /* SessionUtil+Contacts.swift in Sources */, 7B81682328A4C1210069F315 /* UpdateTypes.swift in Sources */, + FD2B4AFF2946C93200AB4848 /* ConfigurationSyncJob.swift in Sources */, FDC438A627BB113A00C60D73 /* UserUnbanRequest.swift in Sources */, FD5C72FB284F0EA10029977D /* MessageReceiver+DataExtractionNotification.swift in Sources */, FDC4386727B4E10E00C60D73 /* Capabilities.swift in Sources */, FDC438A427BB107F00C60D73 /* UserBanRequest.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, - FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */, FDB4BBC72838B91E00B7C95D /* LinkPreviewError.swift in Sources */, FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, B8B320B7258C30D70020074B /* HTMLMetadata.swift in Sources */, + FD8ECF8B2935DB4B00C0D1BB /* SharedConfigMessage.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 */, 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 */, + FD8ECF892935AB7200C0D1BB /* SessionUtilError.swift in Sources */, FD5C7307284F103B0029977D /* MessageReceiver+MessageRequests.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */, 7B93D07127CF194000811CB6 /* MessageRequestResponse.swift in Sources */, + FDF8488829405A9A007DCAE5 /* SOGSBatchRequest.swift in Sources */, FD245C662850665900B966DD /* OpenGroupAPI.swift in Sources */, FD245C5B2850660500B966DD /* ReadReceipt.swift in Sources */, B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */, @@ -5540,7 +5828,10 @@ C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */, FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */, FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */, + FD43EE9F297E2EE0009C87C5 /* SessionUtil+ConvoInfoVolatile.swift in Sources */, B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */, + FDF8487F29405994007DCAE5 /* HTTPHeader+OpenGroup.swift in Sources */, + FD8ECF7D2934293A00C0D1BB /* _013_SessionUtilChanges.swift in Sources */, FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */, FD245C5D2850660F00B966DD /* OWSAudioPlayer.m in Sources */, FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */, @@ -5552,11 +5843,11 @@ FD245C692850666800B966DD /* ExpirationTimerUpdate.swift in Sources */, FD42F9A8285064B800A0C77D /* PushNotificationAPI.swift in Sources */, FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */, + FD43EE9D297A5190009C87C5 /* SessionUtil+UserGroups.swift in Sources */, FD83B9CC27D179BC005E1583 /* FSEndpoint.swift in Sources */, FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */, FD716E6428502DDD00C96BF4 /* CallManagerProtocol.swift in Sources */, FDC438C727BB6DF000C60D73 /* DirectMessage.swift in Sources */, - FDC4384F27B4804F00C60D73 /* Header.swift in Sources */, FD432434299C6985008A0213 /* PendingReadReceipt.swift in Sources */, FDC4381727B32EC700C60D73 /* Personalization.swift in Sources */, FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */, @@ -5565,7 +5856,6 @@ FDC4381527B329CE00C60D73 /* NonceGenerator.swift in Sources */, 7B93D07027CF194000811CB6 /* ConfigurationMessage+Convenience.swift 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 */, @@ -5585,14 +5875,18 @@ FD09796E27FA6D0000936362 /* Contact.swift in Sources */, C38D5E8D2575011E00B6A65C /* MessageSender+ClosedGroups.swift in Sources */, FD5C72F7284F0E560029977D /* MessageReceiver+ReadReceipts.swift in Sources */, - FD83B9CE27D17A04005E1583 /* Request.swift in Sources */, + FD778B6429B189FF001BAC6B /* _014_GenerateInitialUserConfigDumps.swift in Sources */, C32C598A256D0664003C73A2 /* SNProtoEnvelope+Conversion.swift in Sources */, FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */, + FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */, + FDA1E83B29A5F2D500C5C3BD /* SessionUtil+Shared.swift in Sources */, C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */, + FD16AB612A1DD9B60083D849 /* ProfilePictureView+Convenience.swift in Sources */, FDC438C327BB512200C60D73 /* SodiumProtocols.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, C3DB66C3260ACCE6001EFC55 /* OpenGroupPoller.swift in Sources */, FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */, + FD368A6829DE8F9C000DBF1E /* _012_AddFTSIfNeeded.swift in Sources */, FD245C5C2850660A00B966DD /* ConfigurationMessage.swift in Sources */, FD5C7301284F0F7A0029977D /* MessageReceiver+UnsendRequests.swift in Sources */, C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */, @@ -5603,11 +5897,15 @@ FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */, C3C2A74425539EB700C340D1 /* Message.swift in Sources */, FD245C682850666300B966DD /* Message+Destination.swift in Sources */, + FDF8488029405994007DCAE5 /* HTTPQueryParam+OpenGroup.swift in Sources */, FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, FD245C632850664600B966DD /* Configuration.swift in Sources */, C32C5DBF256DD743003C73A2 /* ClosedGroupPoller.swift in Sources */, C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */, + FD2959922A4417A900888A17 /* PreparedSendData.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, + FD432437299DEA38008A0213 /* TypeConversion+Utilities.swift in Sources */, + FD2B4B042949887A00AB4848 /* QueryInterfaceRequest+Utilities.swift in Sources */, FD09797027FA6FF300936362 /* Profile.swift in Sources */, FD245C56285065EA00B966DD /* SNProto.swift in Sources */, FD09798B27FD1CFE00936362 /* Capability.swift in Sources */, @@ -5623,8 +5921,6 @@ 7BCD116C27016062006330F1 /* WebRTCSession+DataChannel.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 */, @@ -5636,6 +5932,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD97B2422A3FEBF30027DD57 /* UnreadMarkerCell.swift in Sources */, FD52090928B59411006098F6 /* ScreenLockUI.swift in Sources */, 7B2561C429874851005C086C /* SessionCarouselView+Info.swift in Sources */, FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */, @@ -5644,7 +5941,6 @@ B8CCF63723961D6D0091D419 /* NewDMVC.swift in Sources */, FDFDE12A282D056B0098B17F /* MediaZoomAnimationController.swift in Sources */, 4C1885D2218F8E1C00B67051 /* PhotoGridViewCell.swift in Sources */, - FD71164C28E3F5AA00B47552 /* SessionCell+ExtraAction.swift in Sources */, 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, 7BFA8AE32831D0D4001876F3 /* ContextMenuVC+EmojiReactsView.swift in Sources */, C3E5C2FA251DBABB0040DFFC /* EditClosedGroupVC.swift in Sources */, @@ -5695,7 +5991,6 @@ C3548F0624456447009433A8 /* PNModeVC.swift in Sources */, FD71164828E2CE8700B47552 /* SessionCell+AccessoryView.swift in Sources */, B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */, - FD71163A28E2C53700B47552 /* SessionAvatarCell.swift in Sources */, 7B3A392E2977791E002FE4AC /* MediaInfoVC.swift in Sources */, 7BA68909272A27BE00EFC32F /* SessionCall.swift in Sources */, B835247925C38D880089A44F /* MessageCell.swift in Sources */, @@ -5727,6 +6022,7 @@ 7BA6890D27325CCC00EFC32F /* SessionCallManager+CXCallController.swift in Sources */, C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */, FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */, + FDF848F329413DB0007DCAE5 /* ImagePickerHandler.swift in Sources */, B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */, FD71160428C95B5600B47552 /* PhotoCollectionPickerViewModel.swift in Sources */, FD37EA1928AC5CCA003AE748 /* NotificationSoundViewModel.swift in Sources */, @@ -5761,10 +6057,9 @@ 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */, 7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */, - FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesViewModel.swift in Sources */, + FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift in Sources */, B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */, 7B7CB190270FB2150079FF93 /* MiniCallView.swift in Sources */, - FD87DCFC28B755B800AF0F98 /* BlockedContactsViewController.swift in Sources */, 7B13E1E92810F01300BD4F64 /* SessionCallManager+Action.swift in Sources */, FD71163728E2C50700B47552 /* SessionTableViewController.swift in Sources */, C354E75A23FE2A7600CE22E3 /* BaseVC.swift in Sources */, @@ -5807,6 +6102,7 @@ 7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */, 7B2561C22978B307005C086C /* MediaInfoVC+MediaInfoView.swift in Sources */, B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */, + FDF848F529413EEC007DCAE5 /* SessionCell+Styling.swift in Sources */, 7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */, B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */, FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */, @@ -5816,10 +6112,11 @@ B894D0752339EDCF00B4D94D /* NukeDataModal.swift in Sources */, 7B93D07727CF1A8A00811CB6 /* MockDataGenerator.swift in Sources */, 7B1B52D828580C6D006069F2 /* EmojiPickerSheet.swift in Sources */, + FD368A6A29DE9E30000DBF1E /* UIContextualAction+Utilities.swift in Sources */, 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */, FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */, FDD2506E283711D600198BDA /* DifferenceKit+Utilities.swift in Sources */, - B897621C25D201F7004F83B2 /* ScrollToBottomButton.swift in Sources */, + B897621C25D201F7004F83B2 /* RoundIconButton.swift in Sources */, 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */, FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */, C302093E25DCBF08001F572D /* MentionSelectionView.swift in Sources */, @@ -5867,10 +6164,13 @@ FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */, FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */, + FD9B30F3293EA0BF008DEE3E /* BatchResponseSpec.swift in Sources */, FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */, + FD0B77B229B82B7A009169BA /* ArrayUtilitiesSpec.swift in Sources */, FD1A94FE2900D2EA000D73D3 /* PersistableRecordUtilitiesSpec.swift in Sources */, FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */, FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */, + FD2959902A43BE5F00888A17 /* VersionSpec.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5879,6 +6179,7 @@ buildActionMask = 2147483647; files = ( FD3C905C27E3FBEF00CD579F /* BatchRequestInfoSpec.swift in Sources */, + FDDC08F229A300E800BF9681 /* LibSessionTypeConversionUtilitiesSpec.swift in Sources */, FD859EFA27C2F5C500510D0C /* MockGenericHash.swift in Sources */, FDC2909427D710B4005DAE71 /* SOGSEndpointSpec.swift in Sources */, FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */, @@ -5886,6 +6187,7 @@ FDC2909127D709CA005DAE71 /* SOGSMessageSpec.swift in Sources */, FD3C906A27E417CE00CD579F /* SodiumUtilitiesSpec.swift in Sources */, FD96F3A729DBD43D00401309 /* MockJobRunner.swift in Sources */, + FDBB25E12983909300F1508E /* ConfigConvoInfoVolatileSpec.swift in Sources */, FD3C907127E445E500CD579F /* MessageReceiverDecryptionSpec.swift in Sources */, FDC2909627D71252005DAE71 /* SOGSErrorSpec.swift in Sources */, FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, @@ -5894,7 +6196,6 @@ FD3C906027E410F700CD579F /* FileUploadResponseSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */, - FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */, FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */, FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */, @@ -5902,18 +6203,23 @@ FD859EF427C2F49200510D0C /* MockSodium.swift in Sources */, FD078E4D27E17156000769AF /* MockOGMCache.swift in Sources */, FD078E5227E1760A000769AF /* OGMDependencyExtensions.swift in Sources */, + FDA1E83629A5748F00C5C3BD /* ConfigUserGroupsSpec.swift in Sources */, FD859EFC27C2F60700510D0C /* MockEd25519.swift in Sources */, FDC290A627D860CE005DAE71 /* Mock.swift in Sources */, + FD2B4AFB29429D1000AB4848 /* ConfigContactsSpec.swift in Sources */, + FDA1E83D29AC71A800C5C3BD /* SessionUtilSpec.swift in Sources */, FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */, FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */, FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */, FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */, FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */, FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, + FD8ECF822934387A00C0D1BB /* ConfigUserProfileSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, - FD3C906227E411AF00CD579F /* HeaderSpec.swift in Sources */, + FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */, FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, + FDA1E83929A5771A00C5C3BD /* LibSessionSpec.swift in Sources */, FD859EF627C2F52C00510D0C /* MockSign.swift in Sources */, FDC2908927D70656005DAE71 /* RoomPollInfoSpec.swift in Sources */, FDFD645D27F273F300808CA1 /* MockGeneralCache.swift in Sources */, @@ -5935,11 +6241,6 @@ target = 453518671FC635DD00210559 /* SessionShareExtension */; targetProxy = 453518701FC635DD00210559 /* PBXContainerItemProxy */; }; - 7B251C3927D82D9E001A6284 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = C3C2A678255388CC00C340D1 /* SessionUtilitiesKit */; - targetProxy = 7B251C3827D82D9E001A6284 /* PBXContainerItemProxy */; - }; 7BC01A41241F40AB00BC7C55 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 7BC01A3A241F40AB00BC7C55 /* SessionNotificationServiceExtension */; @@ -6037,16 +6338,6 @@ 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 */ @@ -6093,7 +6384,7 @@ /* Begin XCBuildConfiguration section */ 453518731FC635DD00210559 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 96ED0C9B69379BE6FF4E9DA6 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig */; + baseConfigurationReference = B1910A32EB2AD01913629646 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -6112,15 +6403,14 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 406; - DEBUG_INFORMATION_FORMAT = dwarf; + CURRENT_PROJECT_VERSION = 419; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "SessionShareExtension/Meta/SessionShareExtension-Prefix.pch"; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "COCOAPODS=1", @@ -6137,7 +6427,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.14; + MARKETING_VERSION = 2.3.0; MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6152,7 +6442,7 @@ }; 453518751FC635DD00210559 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5626DC0D5F62C1C2C64E4AFC /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig */; + baseConfigurationReference = 6A71AD9BEAFF0C9E8016BC23 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionShareExtension.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -6185,7 +6475,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 406; + CURRENT_PROJECT_VERSION = 419; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6194,7 +6484,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "SessionShareExtension/Meta/SessionShareExtension-Prefix.pch"; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "COCOAPODS=1", @@ -6215,7 +6504,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.14; + MARKETING_VERSION = 2.3.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6232,7 +6521,7 @@ }; 7BC01A43241F40AB00BC7C55 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1A0882BF820F5B44969F91F1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */; + baseConfigurationReference = 285705D20F792E174C8A9BBA /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -6251,8 +6540,8 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 406; - DEBUG_INFORMATION_FORMAT = dwarf; + CURRENT_PROJECT_VERSION = 419; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -6274,7 +6563,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.14; + MARKETING_VERSION = 2.3.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -6289,7 +6578,7 @@ }; 7BC01A44241F40AB00BC7C55 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 245BF74EF6348E2D4125033F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig */; + baseConfigurationReference = 62B512CEB14BD4A4A53CF532 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -6325,7 +6614,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 406; + CURRENT_PROJECT_VERSION = 419; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6353,7 +6642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.14; + MARKETING_VERSION = 2.3.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -6370,7 +6659,7 @@ }; C331FF242558F9D400070591 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 510955DC99A0FD84F2D1C159 /* Pods-GlobalDependencies-SessionUIKit.debug.xcconfig */; + baseConfigurationReference = EB5B8ACA4C6F512FA3E21859 /* Pods-GlobalDependencies-SessionUIKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -6390,7 +6679,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -6426,7 +6715,7 @@ }; C331FF252558F9D400070591 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 18EAE958B8C12503F2C294DF /* Pods-GlobalDependencies-SessionUIKit.app store release.xcconfig */; + baseConfigurationReference = 05C76EFA593DD507061C50B2 /* Pods-GlobalDependencies-SessionUIKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -6506,7 +6795,7 @@ }; C33FD9B4255A548A00E217F9 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = DAF57FAAF30631D0E99DA361 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig */; + baseConfigurationReference = B4F9FCBDA07F07CB48220D4C /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -6527,7 +6816,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -6536,7 +6825,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREFIX_HEADER = "SignalUtilitiesKit/Meta/SignalUtilitiesKit-Prefix.pch"; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "COCOAPODS=1", @@ -6571,7 +6859,7 @@ }; C33FD9B5255A548A00E217F9 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 82099864FD91C9126A750313 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */; + baseConfigurationReference = 55C13C7B4B700846E49C0E25 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -6618,7 +6906,6 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; - GCC_PREFIX_HEADER = "SignalUtilitiesKit/Meta/SignalUtilitiesKit-Prefix.pch"; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "COCOAPODS=1", @@ -6659,7 +6946,7 @@ }; C3C2A5A8255385C100C340D1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E23C1E6B7E0C12BF4ACD9CBE /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig */; + baseConfigurationReference = 847091A12D82E41B1EBB8FB3 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -6680,7 +6967,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -6716,7 +7003,7 @@ }; C3C2A5A9255385C100C340D1 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0E836037CC97CE5A47735596 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig */; + baseConfigurationReference = EED1CF82CAB23FE3345564F9 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionSnodeKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -6796,7 +7083,7 @@ }; C3C2A682255388CC00C340D1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F705826F79C4A591AB35D68F /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */; + baseConfigurationReference = 8E946CB54A221018E23599DE /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -6817,7 +7104,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -6838,15 +7125,7 @@ ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - OTHER_LDFLAGS = ( - "$(inherited)", - "-framework", - "\"Foundation\"", - "-framework", - "\"PromiseKit\"", - "-framework", - "\"UIKit\"", - ); + OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionUtilitiesKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; @@ -6862,7 +7141,7 @@ }; C3C2A683255388CC00C340D1 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 2581AFACDDDC1404866D7B8C /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig */; + baseConfigurationReference = F60C5B6CD14329816B0E8CC0 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -6925,15 +7204,7 @@ ); MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - OTHER_LDFLAGS = ( - "$(inherited)", - "-framework", - "\"Foundation\"", - "-framework", - "\"PromiseKit\"", - "-framework", - "\"UIKit\"", - ); + OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionUtilitiesKit"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -6951,7 +7222,7 @@ }; C3C2A6FA25539DE700C340D1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 506FA2159653FF9F446D97D1 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig */; + baseConfigurationReference = 6DA09080DD9779C860023A60 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.debug.xcconfig */; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ANALYZER_NONNULL = YES; @@ -6972,7 +7243,7 @@ CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = SUQ8J2PCT7; DYLIB_COMPATIBILITY_VERSION = 1; @@ -6983,6 +7254,24 @@ GCC_OPTIMIZATION_LEVEL = 0; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_CONFIGURATION_BUILD_DIR}/CocoaLumberjack/CocoaLumberjack.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/Curve25519Kit/Curve25519Kit.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/DifferenceKit/DifferenceKit.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/GRDB.swift/GRDB.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/PureLayout/PureLayout.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/Reachability/Reachability.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/SAMKeychain/SAMKeychain.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/SQLCipher/SQLCipher.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/SignalCoreKit/SignalCoreKit.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/Sodium/Sodium.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/SwiftProtobuf/SwiftProtobuf.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/YapDatabase/YapDatabase.framework/Headers\"", + "\"${PODS_XCFRAMEWORKS_BUILD_DIR}/Sodium/Headers\"", + "$(PODS_ROOT)/SQLCipher", + "${SRCROOT}/LibSession-Util/include/**", + ); INFOPLIST_FILE = SessionMessagingKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -6991,6 +7280,13 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", + "\"${PODS_XCFRAMEWORKS_BUILD_DIR}/Sodium\"", + /usr/lib/swift, + "\"$(TARGET_BUILD_DIR)/libSessionUtil\"", + ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionMessagingKit"; @@ -6998,6 +7294,7 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_INCLUDE_PATHS = "$(inherited) \"${PODS_XCFRAMEWORKS_BUILD_DIR}/Clibsodium\" \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -7008,7 +7305,7 @@ }; C3C2A6FB25539DE700C340D1 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FF694C71BE4B41B6AFD252A0 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig */; + baseConfigurationReference = F390F8E34CA76B3F7D3B1826 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = YES; @@ -7061,6 +7358,24 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_CONFIGURATION_BUILD_DIR}/CocoaLumberjack/CocoaLumberjack.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/Curve25519Kit/Curve25519Kit.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/DifferenceKit/DifferenceKit.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/GRDB.swift/GRDB.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/PureLayout/PureLayout.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/Reachability/Reachability.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/SAMKeychain/SAMKeychain.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/SQLCipher/SQLCipher.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/SignalCoreKit/SignalCoreKit.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/Sodium/Sodium.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/SwiftProtobuf/SwiftProtobuf.framework/Headers\"", + "\"${PODS_CONFIGURATION_BUILD_DIR}/YapDatabase/YapDatabase.framework/Headers\"", + "\"${PODS_XCFRAMEWORKS_BUILD_DIR}/Sodium/Headers\"", + "$(PODS_ROOT)/SQLCipher", + "${SRCROOT}/LibSession-Util/include/**", + ); INFOPLIST_FILE = SessionMessagingKit/Meta/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -7069,6 +7384,13 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "\"${DT_TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}\"", + "\"${PODS_XCFRAMEWORKS_BUILD_DIR}/Sodium\"", + /usr/lib/swift, + "\"$(TARGET_BUILD_DIR)/libSessionUtil\"", + ); MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.SessionMessagingKit"; @@ -7077,6 +7399,7 @@ SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = NO; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_INCLUDE_PATHS = "$(inherited) \"${PODS_XCFRAMEWORKS_BUILD_DIR}/Clibsodium\" \"$(TARGET_BUILD_DIR)/libSessionUtil\""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -7117,7 +7440,6 @@ ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - FRAMEWORK_SEARCH_PATHS = ""; GCC_NO_COMMON_BLOCKS = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", @@ -7193,7 +7515,6 @@ CODE_SIGN_IDENTITY = "iPhone Distribution"; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - 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; @@ -7239,7 +7560,7 @@ }; D221A0BD169C9E5F00537ABF /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 56F41C56FC7B2F381E440FB0 /* Pods-GlobalDependencies-Session.debug.xcconfig */; + baseConfigurationReference = 34040971CC7AF9C8A6C1E838 /* Pods-GlobalDependencies-Session.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -7253,7 +7574,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 406; + CURRENT_PROJECT_VERSION = 419; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7261,7 +7582,6 @@ ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "Session/Meta/Session-Prefix.pch"; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", @@ -7292,7 +7612,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.2.14; + MARKETING_VERSION = 2.3.0; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -7313,7 +7633,7 @@ }; D221A0BE169C9E5F00537ABF /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 6BE8FBF62464A7177034A0AB /* Pods-GlobalDependencies-Session.app store release.xcconfig */; + baseConfigurationReference = 8603226ED1C6F61F1F2D3734 /* Pods-GlobalDependencies-Session.app store release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -7325,7 +7645,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 406; + CURRENT_PROJECT_VERSION = 419; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7333,7 +7653,6 @@ ); GCC_OPTIMIZATION_LEVEL = 3; GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "Session/Meta/Session-Prefix.pch"; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", HAVE_CONFIG_H, @@ -7364,7 +7683,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.2.14; + MARKETING_VERSION = 2.3.0; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; @@ -7382,7 +7701,7 @@ }; FD71161028D00BAE00B47552 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 06160ECE3FE5A06A916FF8C5 /* Pods-GlobalDependencies-Session-SessionTests.debug.xcconfig */; + baseConfigurationReference = 0772459E7D5F6747EDC889F3 /* Pods-GlobalDependencies-Session-SessionTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; @@ -7395,6 +7714,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 = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; @@ -7410,6 +7730,7 @@ MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS $(inherited) -D SQLITE_HAS_CODEC -D GRDBCIPHER -D SQLITE_ENABLE_FTS5 -Xfrontend -warn-long-expression-type-checking=100"; PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -7417,13 +7738,13 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/Session"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; }; name = Debug; }; FD71161128D00BAE00B47552 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0E8564674E3201E218939AFB /* Pods-GlobalDependencies-Session-SessionTests.app store release.xcconfig */; + baseConfigurationReference = F154A10CE1ADA33C16B45357 /* Pods-GlobalDependencies-Session-SessionTests.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; BUNDLE_LOADER = "$(TEST_HOST)"; @@ -7448,6 +7769,214 @@ 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 = 13.0; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionTests; + 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"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Session"; + VALIDATE_PRODUCT = YES; + }; + name = "App Store Release"; + }; + FD83B9B727CF200A005E1583 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8448EFF76CD3CA5B2283B8A0 /* 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 = 13.0; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS $(inherited) -D SQLITE_HAS_CODEC -D GRDBCIPHER -D SQLITE_ENABLE_FTS5 -Xfrontend -warn-long-expression-type-checking=100"; + 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 = 5DA3BDDFFB9E937A49C35FCC /* 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 = 13.0; + 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"; + }; + FD9BDDFD2A5D2294005F1EBC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEFINES_MODULE = YES; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MODULEMAP_FILE = "$(SRCROOT)/SessionMessagingKit/Meta/SessionUtil.modulemap"; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + OTHER_LDFLAGS = "-ObjC"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + FD9BDDFE2A5D2294005F1EBC /* App Store Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; @@ -7457,8 +7986,8 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -7469,123 +7998,15 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0; + MODULEMAP_FILE = "$(SRCROOT)/SessionMessagingKit/Meta/SessionUtil.modulemap"; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionTests; + OTHER_LDFLAGS = "-ObjC"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; + SKIP_INSTALL = YES; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Session.app/Session"; - VALIDATE_PRODUCT = YES; - }; - name = "App Store Release"; - }; - 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 = 13.0; - 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 = 13.0; - 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"; @@ -7595,7 +8016,7 @@ }; FDC4389627B9FFC700C60D73 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D0CE0424239A1574F683D2D7 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig */; + baseConfigurationReference = 8727C47348B6EFA767EE583A /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -7624,6 +8045,7 @@ MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS $(inherited) -D SQLITE_HAS_CODEC -D GRDBCIPHER -D SQLITE_ENABLE_FTS5 -Xfrontend -warn-long-expression-type-checking=100"; PRODUCT_BUNDLE_IDENTIFIER = io.oxen.SessionMessagingKitTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -7636,7 +8058,7 @@ }; FDC4389727B9FFC700C60D73 /* App Store Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8E029A324780A800DE6B70B3 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig */; + baseConfigurationReference = 621B42AC592F3456ACD82F8B /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests.app store release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; @@ -7801,6 +8223,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = "App Store Release"; }; + FD9BDDFC2A5D2294005F1EBC /* Build configuration list for PBXNativeTarget "SessionUtil" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FD9BDDFD2A5D2294005F1EBC /* Debug */, + FD9BDDFE2A5D2294005F1EBC /* App Store Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = "App Store Release"; + }; FDC4389527B9FFC700C60D73 /* Build configuration list for PBXNativeTarget "SessionMessagingKitTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Session.xcworkspace/xcshareddata/Signal.xcscmblueprint b/Session.xcworkspace/xcshareddata/Signal.xcscmblueprint deleted file mode 100644 index 15a2ce9a2..000000000 --- a/Session.xcworkspace/xcshareddata/Signal.xcscmblueprint +++ /dev/null @@ -1,121 +0,0 @@ -{ - "DVTSourceControlWorkspaceBlueprintPrimaryRemoteRepositoryKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++2D5CBAE", - "DVTSourceControlWorkspaceBlueprintWorkingCopyRepositoryLocationsKey" : { - - }, - "DVTSourceControlWorkspaceBlueprintWorkingCopyStatesKey" : { - "8176314449001F06FB0E5B588C62133EAA2FE911+++72E8629" : 9223372036854775807, - "01DE8628B025BC69C8C7D8B4612D57BE2C08B62C+++6A1C9FC" : 0, - "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++0BB03DB" : 0, - "ABB939127996C66F7E852A780552ADEEF03C6B13+++69179A3" : 0, - "90530B99EB0008E7A50951FDFBE02169118FA649+++EF2C0B3" : 0, - "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++ED4C31A" : 0, - "D74FB800F048CB516BB4BC70047F7CC676D291B9+++375B249" : 0, - "8176314449001F06FB0E5B588C62133EAA2FE911+++692B8E4" : 9223372036854775807, - "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++901E7D4" : 0, - "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++2D5CBAE" : 0, - "8176314449001F06FB0E5B588C62133EAA2FE911+++E19D6E3" : 9223372036854775807, - "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++03D0758" : 0, - "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++3F8B703" : 9223372036854775807, - "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++E57A04A" : 0, - "8176314449001F06FB0E5B588C62133EAA2FE911+++31C7255" : 9223372036854775807 - }, - "DVTSourceControlWorkspaceBlueprintIdentifierKey" : "D0F297E7-A82D-4657-A941-96B268F80ABC", - "DVTSourceControlWorkspaceBlueprintWorkingCopyPathsKey" : { - "8176314449001F06FB0E5B588C62133EAA2FE911+++72E8629" : "Signal-iOS-2\/Carthage\/", - "01DE8628B025BC69C8C7D8B4612D57BE2C08B62C+++6A1C9FC" : "SignalProtocolKit\/", - "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++0BB03DB" : "Signal-iOS-2\/", - "ABB939127996C66F7E852A780552ADEEF03C6B13+++69179A3" : "SocketRocket\/", - "90530B99EB0008E7A50951FDFBE02169118FA649+++EF2C0B3" : "JSQMessagesViewController\/", - "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++ED4C31A" : "Signal-iOS\/", - "D74FB800F048CB516BB4BC70047F7CC676D291B9+++375B249" : "Signal-iOS\/Pods\/", - "8176314449001F06FB0E5B588C62133EAA2FE911+++692B8E4" : "Signal-iOS-4\/Carthage\/", - "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++901E7D4" : "SignalServiceKit\/", - "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++2D5CBAE" : "Signal-iOS-4\/", - "8176314449001F06FB0E5B588C62133EAA2FE911+++E19D6E3" : "Signal-iOS\/Carthage\/", - "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++03D0758" : "Signal-iOS-5\/", - "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++3F8B703" : "SignalServiceKit-2\/", - "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++E57A04A" : "SignalServiceKit\/", - "8176314449001F06FB0E5B588C62133EAA2FE911+++31C7255" : "Signal-iOS-5\/Carthage\/" - }, - "DVTSourceControlWorkspaceBlueprintNameKey" : "Signal", - "DVTSourceControlWorkspaceBlueprintVersion" : 204, - "DVTSourceControlWorkspaceBlueprintRelativePathToProjectKey" : "Signal.xcworkspace", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoriesKey" : [ - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/SignalProtocolKit.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "01DE8628B025BC69C8C7D8B4612D57BE2C08B62C+++6A1C9FC" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/SignalServiceKit.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++3F8B703" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/SignalProtocolKit.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++901E7D4" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:FredericJacobs\/TextSecureKit.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++E57A04A" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-iOS.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++03D0758" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-iOS.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++0BB03DB" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-iOS.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++2D5CBAE" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-iOS.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++ED4C31A" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:michaelkirk\/Signal-Carthage.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "8176314449001F06FB0E5B588C62133EAA2FE911+++31C7255" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/github.com\/WhisperSystems\/Signal-Carthage.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "8176314449001F06FB0E5B588C62133EAA2FE911+++692B8E4" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/github.com\/WhisperSystems\/Signal-Carthage.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "8176314449001F06FB0E5B588C62133EAA2FE911+++72E8629" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-Carthage.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "8176314449001F06FB0E5B588C62133EAA2FE911+++E19D6E3" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/JSQMessagesViewController.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "90530B99EB0008E7A50951FDFBE02169118FA649+++EF2C0B3" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/SocketRocket.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "ABB939127996C66F7E852A780552ADEEF03C6B13+++69179A3" - }, - { - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/github.com\/FredericJacobs\/Precompiled-Signal-Dependencies.git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git", - "DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "D74FB800F048CB516BB4BC70047F7CC676D291B9+++375B249" - } - ] -} \ No newline at end of file diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index de3f64f48..53b392657 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -1,10 +1,12 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import UIKit +import YYImage +import Combine import CallKit import GRDB import WebRTC -import PromiseKit +import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit @@ -25,6 +27,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let contactName: String let profilePicture: UIImage + let animatedProfilePicture: YYImage? // MARK: - Control @@ -151,10 +154,18 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid) self.isOutgoing = outgoing + let avatarData: Data? = ProfileManager.profileAvatar(db, id: sessionId) self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact) - self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId) + self.profilePicture = avatarData .map { UIImage(data: $0) } - .defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300)) + .defaulting(to: PlaceholderIcon.generate(seed: sessionId, text: self.contactName, size: 300)) + self.animatedProfilePicture = avatarData + .map { data in + switch data.guessedImageFormat { + case .gif, .webp: return YYImage(data: data) + default: return nil + } + } WebRTCSession.current = self.webRTCSession self.webRTCSession.delegate = self @@ -206,6 +217,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { return } + let webRTCSession: WebRTCSession = self.webRTCSession let timestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs() let message: CallMessage = CallMessage( uuid: self.uuid, @@ -224,21 +236,18 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { .inserted(db) self.callInteractionId = interaction?.id - try? self.webRTCSession + + try? 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() + // Start the timeout timer for the call + .handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() }) + .flatMap { _ in webRTCSession.sendOffer(to: thread) } + .sinkUntilComplete() } func answerSessionCall() { @@ -418,9 +427,14 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { let sessionId: String = self.sessionId let webRTCSession: WebRTCSession = self.webRTCSession - Storage.shared - .read { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) } - .retainUntilComplete() + guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: sessionId) }) else { + return + } + + webRTCSession + .sendOffer(to: thread, isRestartingICEConnection: true) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() } // MARK: - Timeout diff --git a/Session/Calls/Call Management/SessionCallManager+CXProvider.swift b/Session/Calls/Call Management/SessionCallManager+CXProvider.swift index 612742bc5..9f122ebcd 100644 --- a/Session/Calls/Call Management/SessionCallManager+CXProvider.swift +++ b/Session/Calls/Call Management/SessionCallManager+CXProvider.swift @@ -2,8 +2,8 @@ import Foundation import CallKit -import SignalCoreKit import SessionUtilitiesKit +import SignalCoreKit extension SessionCallManager: CXProviderDelegate { public func providerDidReset(_ provider: CXProvider) { diff --git a/Session/Calls/Call Management/SessionCallManager.swift b/Session/Calls/Call Management/SessionCallManager.swift index b33177ff7..18fa498da 100644 --- a/Session/Calls/Call Management/SessionCallManager.swift +++ b/Session/Calls/Call Management/SessionCallManager.swift @@ -4,6 +4,8 @@ import UIKit import CallKit import GRDB import SessionMessagingKit +import SignalCoreKit +import SignalUtilitiesKit public final class SessionCallManager: NSObject, CallManagerProtocol { let provider: CXProvider? @@ -205,9 +207,9 @@ public final class SessionCallManager: NSObject, CallManagerProtocol { return } - guard CurrentAppContext().isMainAppAndActive else { return } - DispatchQueue.main.async { + guard CurrentAppContext().isMainAppAndActive else { return } + guard let presentingVC = CurrentAppContext().frontmostViewController() else { preconditionFailure() // FIXME: Handle more gracefully } diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 44dab72c8..c7657d8a5 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -1,15 +1,16 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import YYImage import MediaPlayer -import WebRTC import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit final class CallVC: UIViewController, VideoPreviewDelegate { - static let floatingVideoViewWidth: CGFloat = UIDevice.current.isIPad ? 160 : 80 - static let floatingVideoViewHeight: CGFloat = UIDevice.current.isIPad ? 346: 173 + private static let avatarRadius: CGFloat = (isIPhone6OrSmaller ? 100 : 120) + private static let floatingVideoViewWidth: CGFloat = (UIDevice.current.isIPad ? 160 : 80) + private static let floatingVideoViewHeight: CGFloat = (UIDevice.current.isIPad ? 346: 173) let call: SessionCall var latestKnownAudioOutputDeviceName: String? @@ -129,17 +130,29 @@ final class CallVC: UIViewController, VideoPreviewDelegate { private lazy var profilePictureView: UIImageView = { let result = UIImageView() - let radius: CGFloat = isIPhone6OrSmaller ? 100 : 120 result.image = self.call.profilePicture - result.set(.width, to: radius * 2) - result.set(.height, to: radius * 2) - result.layer.cornerRadius = radius + result.set(.width, to: CallVC.avatarRadius * 2) + result.set(.height, to: CallVC.avatarRadius * 2) + result.layer.cornerRadius = CallVC.avatarRadius result.layer.masksToBounds = true result.contentMode = .scaleAspectFill return result }() + private lazy var animatedImageView: YYAnimatedImageView = { + let result: YYAnimatedImageView = YYAnimatedImageView() + result.image = self.call.animatedProfilePicture + result.set(.width, to: CallVC.avatarRadius * 2) + result.set(.height, to: CallVC.avatarRadius * 2) + result.layer.cornerRadius = CallVC.avatarRadius + result.layer.masksToBounds = true + result.contentMode = .scaleAspectFill + result.isHidden = (self.call.animatedProfilePicture == nil) + + return result + }() + private lazy var minimizeButton: UIButton = { let result = UIButton(type: .custom) result.setImage( @@ -486,7 +499,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate { profilePictureContainer.pin(.bottom, to: .top, of: operationPanel) profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view) profilePictureContainer.addSubview(profilePictureView) + profilePictureContainer.addSubview(animatedImageView) profilePictureView.center(in: profilePictureContainer) + animatedImageView.center(in: profilePictureContainer) // Call info label let callInfoLabelContainer = UIView() diff --git a/Session/Calls/VideoPreviewVC.swift b/Session/Calls/VideoPreviewVC.swift index 4311d961b..b75b05b16 100644 --- a/Session/Calls/VideoPreviewVC.swift +++ b/Session/Calls/VideoPreviewVC.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import WebRTC import SessionUIKit public protocol VideoPreviewDelegate: AnyObject { diff --git a/Session/Calls/Views & Modals/CallVideoView.swift b/Session/Calls/Views & Modals/CallVideoView.swift index d2e8894c0..e0b73e5e7 100644 --- a/Session/Calls/Views & Modals/CallVideoView.swift +++ b/Session/Calls/Views & Modals/CallVideoView.swift @@ -1,6 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import WebRTC import Foundation +import SessionUtilitiesKit +import SignalCoreKit #if targetEnvironment(simulator) // Note: 'RTCMTLVideoView' doesn't seem to work on the simulator so use 'RTCEAGLVideoView' instead @@ -27,7 +30,7 @@ class RemoteVideoView: TargetView { return } - DispatchMainThreadSafe { + Threading.dispatchMainThreadSafe { let frameRatio = Double(frame.height) / Double(frame.width) let frameRotation = frame.rotation let deviceRotation = UIDevice.current.orientation @@ -90,7 +93,8 @@ class LocalVideoView: TargetView { override func renderFrame(_ frame: RTCVideoFrame?) { super.renderFrame(frame) - DispatchMainThreadSafe { + + Threading.dispatchMainThreadSafe { // This is a workaround for a weird issue that // sometimes the rotationOverride is not working // if it is only set once on initialization diff --git a/Session/Calls/Views & Modals/IncomingCallBanner.swift b/Session/Calls/Views & Modals/IncomingCallBanner.swift index cecb6c01b..7646903a2 100644 --- a/Session/Calls/Views & Modals/IncomingCallBanner.swift +++ b/Session/Calls/Views & Modals/IncomingCallBanner.swift @@ -1,9 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import WebRTC import SessionUIKit import SessionMessagingKit +import SignalUtilitiesKit final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { private static let swipeToOperateThreshold: CGFloat = 60 @@ -20,14 +20,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { return result }() - private lazy var profilePictureView: ProfilePictureView = { - let result = ProfilePictureView() - let size: CGFloat = 60 - result.size = size - result.set(.width, to: size) - result.set(.height, to: size) - return result - }() + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list) private lazy var displayNameLabel: UILabel = { let result = UILabel() @@ -118,8 +111,10 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate { profilePictureView.update( publicKey: call.sessionId, - profile: Profile.fetchOrCreate(id: call.sessionId), - threadVariant: .contact + threadVariant: .contact, + customImageData: nil, + profile: Storage.shared.read { db in Profile.fetchOrCreate(db, id: call.sessionId) }, + additionalProfile: nil ) displayNameLabel.text = call.contactName diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index bc7f80f8e..07475227c 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -1,9 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import GRDB import DifferenceKit -import PromiseKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit @@ -18,6 +18,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat } private let threadId: String + private let threadVariant: SessionThread.Variant private var originalName: String = "" private var originalMembersAndZombieIds: Set = [] private var name: String = "" @@ -82,8 +83,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat // MARK: - Lifecycle - init(threadId: String) { + init(threadId: String, threadVariant: SessionThread.Variant) { self.threadId = threadId + self.threadVariant = threadVariant super.init(nibName: nil, bundle: nil) } @@ -220,7 +222,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat cell.update( with: SessionCell.Info( id: displayInfo, - leftAccessory: .profile(displayInfo.profileId, displayInfo.profile), + position: Position.with(indexPath.row, count: membersAndZombies.count), + leftAccessory: .profile(id: displayInfo.profileId, profile: displayInfo.profile), title: ( displayInfo.profile?.displayName() ?? Profile.truncated(id: displayInfo.profileId, threadVariant: .contact) @@ -231,10 +234,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat .withRenderingMode(.alwaysTemplate), customTint: .textSecondary ) - ) - ), - style: .edgeToEdge, - position: Position.with(indexPath.row, count: membersAndZombies.count) + ), + styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge) + ) ) return cell @@ -244,12 +246,26 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat return adminIds.contains(userPublicKey) } + func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) { + UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView) + } + + func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) { + UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView) + } + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let profileId: String = self.membersAndZombies[indexPath.row].profileId let delete: UIContextualAction = UIContextualAction( - style: .destructive, - title: "GROUP_ACTION_REMOVE".localized() + title: "GROUP_ACTION_REMOVE".localized(), + icon: UIImage(named: "icon_bin"), + themeTintColor: .white, + themeBackgroundColor: .conversationButton_swipeDestructive, + side: .trailing, + actionIndex: 0, + indexPath: indexPath, + tableView: tableView ) { [weak self] _, _, completionHandler in self?.adminIds.remove(profileId) self?.membersAndZombies.remove(at: indexPath.row) @@ -257,7 +273,6 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat completionHandler(true) } - delete.themeBackgroundColor = .conversationButton_swipeDestructive return UISwipeActionsConfiguration(actions: [ delete ]) } @@ -286,7 +301,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat } private func handleMembersChanged() { - tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67 + tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 78 tableView.reloadData() } @@ -333,7 +348,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat guard !updatedName.isEmpty else { return showError(title: "vc_create_closed_group_group_name_missing_error".localized()) } - guard updatedName.count < 64 else { + guard updatedName.utf8CString.count < SessionUtil.libSessionMaxGroupNameByteLength else { return showError(title: "vc_create_closed_group_group_name_too_long_error".localized()) } @@ -449,32 +464,40 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in Storage.shared - .writeAsync { db in - if !updatedMemberIds.contains(userPublicKey) { - try MessageSender.leave( - db, - groupPublicKey: threadId, - deleteThread: false - ) - return Promise.value(()) - } - - return try MessageSender.update( + .writePublisher { db in + // If the user is no longer a member then leave the group + guard !updatedMemberIds.contains(userPublicKey) else { return } + + try MessageSender.leave( db, + groupPublicKey: threadId, + deleteThread: true + ) + + } + .flatMap { + MessageSender.update( groupPublicKey: threadId, with: updatedMemberIds, name: updatedName ) } - .done(on: DispatchQueue.main) { [weak self] in - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - popToConversationVC(self) - } - .catch(on: DispatchQueue.main) { [weak self] error in - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - self?.showError(title: "GROUP_UPDATE_ERROR_TITLE".localized(), message: error.localizedDescription) - } - .retainUntilComplete() + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { [weak self] result in + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + + switch result { + case .finished: popToConversationVC(self) + case .failure(let error): + self?.showError( + title: "GROUP_UPDATE_ERROR_TITLE".localized(), + message: error.localizedDescription + ) + } + } + ) } } diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index d5cc50a83..7e4a1eb51 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -3,7 +3,6 @@ import UIKit import GRDB import DifferenceKit -import PromiseKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit @@ -208,15 +207,17 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate cell.update( with: SessionCell.Info( id: profile, - leftAccessory: .profile(profile.id, profile), + position: Position.with(indexPath.row, count: data[indexPath.section].elements.count), + leftAccessory: .profile(id: profile.id, profile: profile), title: profile.displayName(), rightAccessory: .radio(isSelected: { [weak self] in self?.selectedContacts.contains(profile.id) == true }), - accessibilityIdentifier: "Contact" - ), - style: .edgeToEdge, - position: Position.with(indexPath.row, count: data[indexPath.section].elements.count) + styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), + accessibility: Accessibility( + identifier: "Contact" + ) + ) ) return cell @@ -319,7 +320,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate else { return showError(title: "vc_create_closed_group_group_name_missing_error".localized()) } - guard name.count < 30 else { + guard name.utf8CString.count < SessionUtil.libSessionMaxGroupNameByteLength else { return showError(title: "vc_create_closed_group_group_name_too_long_error".localized()) } guard selectedContacts.count >= 1 else { @@ -331,33 +332,38 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate let selectedContacts = self.selectedContacts let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil) ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in - 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 modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: "GROUP_CREATION_ERROR_TITLE".localized(), - body: .text("GROUP_CREATION_ERROR_MESSAGE".localized()), - cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text + MessageSender + .createClosedGroup(name: name, members: selectedContacts) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: "GROUP_CREATION_ERROR_TITLE".localized(), + body: .text("GROUP_CREATION_ERROR_MESSAGE".localized()), + cancelTitle: "BUTTON_OK".localized(), + cancelStyle: .alert_text + ) + ) + self?.present(modal, animated: true) + } + }, + receiveValue: { thread in + SessionApp.presentConversationCreatingIfNeeded( + for: thread.id, + variant: thread.variant, + dismissing: self?.presentingViewController, + animated: false ) - ) - self?.present(modal, animated: true) - } - .retainUntilComplete() + } + ) } } } diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index a21818707..f71cfde88 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -131,12 +131,23 @@ extension ContextMenuVC { ) { delegate?.contextMenuDismissed() } } } + + static func viewModelCanReply(_ cellViewModel: MessageViewModel) -> Bool { + return ( + cellViewModel.variant == .standardIncoming || ( + cellViewModel.variant == .standardOutgoing && + cellViewModel.state != .failed && + cellViewModel.state != .sending + ) + ) + } static func actions( for cellViewModel: MessageViewModel, recentEmojis: [EmojiWithSkinTones], currentUserPublicKey: String, - currentUserBlindedPublicKey: String?, + currentUserBlinded15PublicKey: String?, + currentUserBlinded25PublicKey: String?, currentUserIsOpenGroupModerator: Bool, currentThreadIsMessageRequest: Bool, delegate: ContextMenuActionDelegate? @@ -161,12 +172,6 @@ extension ContextMenuVC { ) ) ) - let canReply: Bool = ( - cellViewModel.variant != .standardOutgoing || ( - cellViewModel.state != .failed && - cellViewModel.state != .sending - ) - ) let canCopy: Bool = ( cellViewModel.cellType == .textOnlyMessage || ( ( @@ -194,23 +199,27 @@ extension ContextMenuVC { ) let canCopySessionId: Bool = ( cellViewModel.variant == .standardIncoming && - cellViewModel.threadVariant != .openGroup + cellViewModel.threadVariant != .community ) let canDelete: Bool = ( - cellViewModel.threadVariant != .openGroup || + cellViewModel.threadVariant != .community || currentUserIsOpenGroupModerator || cellViewModel.authorId == currentUserPublicKey || - cellViewModel.authorId == currentUserBlindedPublicKey || + cellViewModel.authorId == currentUserBlinded15PublicKey || + cellViewModel.authorId == currentUserBlinded25PublicKey || cellViewModel.state == .failed ) let canBan: Bool = ( - cellViewModel.threadVariant == .openGroup && + cellViewModel.threadVariant == .community && currentUserIsOpenGroupModerator ) let shouldShowEmojiActions: Bool = { - if cellViewModel.threadVariant == .openGroup { - return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer) + if cellViewModel.threadVariant == .community { + return OpenGroupManager.doesOpenGroupSupport( + capability: .reactions, + on: cellViewModel.threadOpenGroupServer + ) } return !currentThreadIsMessageRequest }() @@ -219,7 +228,7 @@ extension ContextMenuVC { let generatedActions: [Action] = [ (canRetry ? Action.retry(cellViewModel, delegate) : nil), - (canReply ? Action.reply(cellViewModel, delegate) : nil), + (viewModelCanReply(cellViewModel) ? Action.reply(cellViewModel, delegate) : nil), (canCopy ? Action.copy(cellViewModel, delegate) : nil), (canSave ? Action.save(cellViewModel, delegate) : nil), (canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil), diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 3c9d9aab0..8bd65da55 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -71,10 +71,12 @@ final class ContextMenuVC: UIViewController { private lazy var fallbackTimestampLabel: UILabel = { let result: UILabel = UILabel() + result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) result.font = .systemFont(ofSize: Values.verySmallFontSize) result.text = cellViewModel.dateForUI.formattedForDisplay result.themeTextColor = .textPrimary result.alpha = 0 + result.numberOfLines = 2 return result }() @@ -189,10 +191,14 @@ final class ContextMenuVC: UIViewController { fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight) if cellViewModel.variant == .standardOutgoing { + fallbackTimestampLabel.textAlignment = .right fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing) + fallbackTimestampLabel.pin(.left, to: .left, of: view, withInset: Values.mediumSpacing) } else { + fallbackTimestampLabel.textAlignment = .left fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing) + fallbackTimestampLabel.pin(.right, to: .right, of: view, withInset: -Values.mediumSpacing) } // Constrains diff --git a/Session/Conversations/Context Menu/ContextMenuWindow.swift b/Session/Conversations/Context Menu/ContextMenuWindow.swift index 7e309c199..7970811fc 100644 --- a/Session/Conversations/Context Menu/ContextMenuWindow.swift +++ b/Session/Conversations/Context Menu/ContextMenuWindow.swift @@ -1,3 +1,6 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit final class ContextMenuWindow : UIWindow { diff --git a/Session/Conversations/ConversationSearch.swift b/Session/Conversations/ConversationSearch.swift index 87a43e6a0..785578104 100644 --- a/Session/Conversations/ConversationSearch.swift +++ b/Session/Conversations/ConversationSearch.swift @@ -3,6 +3,8 @@ import UIKit import GRDB import SignalUtilitiesKit +import SignalCoreKit +import SessionUIKit public class StyledSearchController: UISearchController { public override var preferredStatusBarStyle: UIStatusBarStyle { @@ -83,7 +85,7 @@ extension ConversationSearchController: UISearchResultsUpdating { let threadId: String = self.threadId DispatchQueue.global(qos: .default).async { [weak self] in - let results: [Int64]? = Storage.shared.read { db -> [Int64] in + let results: [Interaction.TimestampInfo]? = Storage.shared.read { db -> [Interaction.TimestampInfo] in self?.resultsBar.willStartSearching(readConnection: db) return try Interaction.idsForTermWithin( @@ -96,7 +98,7 @@ extension ConversationSearchController: UISearchResultsUpdating { // If we didn't get results back then we most likely interrupted the query so // should ignore the results (if there are no results we would succeed and get // an empty array back) - guard let results: [Int64] = results else { return } + guard let results: [Interaction.TimestampInfo] = results else { return } DispatchQueue.main.async { guard let strongSelf = self else { return } @@ -115,11 +117,11 @@ extension ConversationSearchController: SearchResultsBarDelegate { func searchResultsBar( _ searchResultsBar: SearchResultsBar, setCurrentIndex currentIndex: Int, - results: [Int64] + results: [Interaction.TimestampInfo] ) { - guard let interactionId: Int64 = results[safe: currentIndex] else { return } + guard let interactionInfo: Interaction.TimestampInfo = results[safe: currentIndex] else { return } - self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId) + self.delegate?.conversationSearchController(self, didSelectInteractionInfo: interactionInfo) } } @@ -127,13 +129,13 @@ protocol SearchResultsBarDelegate: AnyObject { func searchResultsBar( _ searchResultsBar: SearchResultsBar, setCurrentIndex currentIndex: Int, - results: [Int64] + results: [Interaction.TimestampInfo] ) } public final class SearchResultsBar: UIView { private var readConnection: Atomic = Atomic(nil) - private var results: Atomic<[Int64]?> = Atomic(nil) + private var results: Atomic<[Interaction.TimestampInfo]?> = Atomic(nil) var currentIndex: Int? weak var resultsBarDelegate: SearchResultsBarDelegate? @@ -248,7 +250,7 @@ public final class SearchResultsBar: UIView { // MARK: - Actions @objc public func handleUpButtonTapped() { - guard let results: [Int64] = results.wrappedValue else { return } + guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return } guard let currentIndex: Int = currentIndex else { return } guard currentIndex + 1 < results.count else { return } @@ -260,7 +262,7 @@ public final class SearchResultsBar: UIView { @objc public func handleDownButtonTapped() { Logger.debug("") - guard let results: [Int64] = results.wrappedValue else { return } + guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return } guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return } let newIndex = currentIndex - 1 @@ -287,12 +289,12 @@ public final class SearchResultsBar: UIView { self.readConnection.mutate { $0 = readConnection } } - func updateResults(results: [Int64]?) { + func updateResults(results: [Interaction.TimestampInfo]?) { // We want to ignore search results that don't match the current searchId (this // will happen when searching large threads with short terms as the shorter terms // will take much longer to resolve than the longer terms) currentIndex = { - guard let results: [Int64] = results, !results.isEmpty else { return nil } + guard let results: [Interaction.TimestampInfo] = results, !results.isEmpty else { return nil } if let currentIndex: Int = currentIndex { return max(0, min(currentIndex, results.count - 1)) @@ -312,10 +314,11 @@ public final class SearchResultsBar: UIView { } func updateBarItems() { - guard let results: [Int64] = results.wrappedValue else { + guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { label.text = "" downButton.isEnabled = false upButton.isEnabled = false + stopLoading() return } @@ -362,6 +365,6 @@ public final class SearchResultsBar: UIView { // MARK: - ConversationSearchControllerDelegate public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate { - func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) - func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64) + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Interaction.TimestampInfo]?, searchText: String?) + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo: Interaction.TimestampInfo) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 6f8cfb32b..f597cc418 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1,11 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import CoreServices import Photos import PhotosUI import Sodium -import PromiseKit import GRDB import SessionUIKit import SessionMessagingKit @@ -16,7 +16,6 @@ extension ConversationVC: InputViewDelegate, MessageCellDelegate, ContextMenuActionDelegate, - ScrollToBottomButtonDelegate, SendMediaNavDelegate, UIDocumentPickerDelegate, AttachmentApprovalViewControllerDelegate, @@ -51,27 +50,18 @@ extension ConversationVC: navigationController?.pushViewController(viewController, animated: true) } - // 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. - scrollToBottom(isAnimated: true) - } - // MARK: - Call @objc func startCall(_ sender: Any?) { guard SessionCall.isEnabled else { return } + guard viewModel.threadData.threadIsBlocked == false else { return } guard Storage.shared[.areCallsEnabled] else { let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modal_call_permission_request_title".localized(), body: .text("modal_call_permission_request_explanation".localized()), confirmTitle: "vc_settings_title".localized(), - confirmAccessibilityLabel: "Settings", - cancelAccessibilityLabel: "Cancel", + confirmAccessibility: Accessibility(identifier: "Settings"), dismissOnConfirm: false // Custom dismissal logic ) { [weak self] _ in self?.dismiss(animated: true) { @@ -140,8 +130,8 @@ extension ConversationVC: ) ), confirmTitle: "modal_blocked_button_title".localized(), - confirmAccessibilityLabel: "Confirm block", - cancelAccessibilityLabel: "Cancel block", + confirmAccessibility: Accessibility(identifier: "Confirm block"), + cancelAccessibility: Accessibility(identifier: "Cancel block"), dismissOnConfirm: false // Custom dismissal logic ) { [weak self] _ in self?.viewModel.unblockContact() @@ -160,10 +150,17 @@ extension ConversationVC: } func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { - sendAttachments(attachments, with: messageText ?? "") - self.snInputView.text = "" + sendMessage(text: (messageText ?? ""), attachments: attachments) resetMentions() - dismiss(animated: true) { } + + dismiss(animated: true) { [weak self] in + if self?.isFirstResponder == false { + self?.becomeFirstResponder() + } + else { + self?.reloadInputViews() + } + } } func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? { @@ -177,13 +174,17 @@ extension ConversationVC: // 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) - self.snInputView.text = "" + sendMessage(text: (messageText ?? ""), attachments: attachments) resetMentions() + + dismiss(animated: true) { [weak self] in + if self?.isFirstResponder == false { + self?.becomeFirstResponder() + } + else { + self?.reloadInputViews() + } + } } func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) { @@ -191,7 +192,7 @@ extension ConversationVC: } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) { - snInputView.text = newMessageText ?? "" + snInputView.text = (newMessageText ?? "") } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { @@ -358,6 +359,7 @@ extension ConversationVC: attachments: attachments, approvalDelegate: self ) + navController.modalPresentationStyle = .fullScreen present(navController, animated: true, completion: nil) } @@ -372,23 +374,21 @@ extension ConversationVC: 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 - } + .attachmentPublisher + .sinkUntilComplete( + receiveValue: { [weak self] attachment in + guard !modalActivityIndicator.wasCancelled else { return } - self?.showAttachmentApprovalDialog(for: [ attachment ]) + modalActivityIndicator.dismiss { + guard !attachment.hasError else { + self?.showErrorAlert(for: attachment) + return + } + + self?.showAttachmentApprovalDialog(for: [ attachment ]) + } } - } - .retainUntilComplete() + ) } } @@ -397,147 +397,33 @@ extension ConversationVC: // MARK: --Message Sending func handleSendButtonTapped() { - sendMessage() - } - - func sendMessage(hasPermissionToSendSeed: Bool = false) { - guard !showBlockedModalIfNeeded() else { return } - - let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) - - guard !text.isEmpty else { return } - - if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { - // Warn the user if they're about to send their seed to someone - let modal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "modal_send_seed_title".localized(), - body: .text("modal_send_seed_explanation".localized()), - confirmTitle: "modal_send_seed_send_button_title".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - onConfirm: { [weak self] _ in self?.sendMessage(hasPermissionToSendSeed: true) } - ) - ) - - return present(modal, animated: true, completion: nil) - } - - // Clearing this out immediately to make this appear more snappy - 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 threadId: String = self.viewModel.threadData.threadId - let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true) - let sentTimestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs() - let linkPreviewDraft: LinkPreviewDraft? = snInputView.linkPreviewInfo?.draft - let quoteModel: QuotedReplyModel? = snInputView.quoteDraftInfo?.model - - // If this was a message request then approve it - approveMessageRequestIfNeeded( - for: threadId, - threadVariant: self.viewModel.threadData.threadVariant, - isNewThread: !oldThreadShouldBeVisible, - timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting - ) - - // 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)) - - let authorId: String = { - if let blindedId = self?.viewModel.threadData.currentUserBlindedPublicKey { - return blindedId - } - return self?.viewModel.threadData.currentUserPublicKey ?? getUserHexEncodedPublicKey(db) - }() - - // Create the interaction - let interaction: Interaction = try Interaction( - threadId: threadId, - authorId: authorId, - variant: .standardOutgoing, - body: text, - timestampMs: sentTimestampMs, - hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: text), - expiresInSeconds: try? DisappearingMessagesConfiguration - .select(.durationSeconds) - .filter(id: threadId) - .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) - .asRequest(of: TimeInterval.self) - .fetchOne(db), - 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() - } + sendMessage( + text: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines), + linkPreviewDraft: snInputView.linkPreviewInfo?.draft, + quoteModel: snInputView.quoteDraftInfo?.model ) } - func sendAttachments(_ attachments: [SignalAttachment], with text: String, hasPermissionToSendSeed: Bool = false, onComplete: (() -> ())? = nil) { + func sendMessage( + text: String, + attachments: [SignalAttachment] = [], + linkPreviewDraft: LinkPreviewDraft? = nil, + quoteModel: QuotedReplyModel? = nil, + hasPermissionToSendSeed: Bool = false + ) { guard !showBlockedModalIfNeeded() else { return } - for attachment in attachments { - if attachment.hasError { - return showErrorAlert(for: attachment, onDismiss: onComplete) - } + // Handle attachment errors if applicable + if let failedAttachment: SignalAttachment = attachments.first(where: { $0.hasError }) { + return showErrorAlert(for: failedAttachment) } - let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)) + let processedText: String = replaceMentions(in: text.trimmingCharacters(in: .whitespacesAndNewlines)) - if text.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { + // If we have no content then do nothing + guard !processedText.isEmpty || !attachments.isEmpty else { return } + + if processedText.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed { // Warn the user if they're about to send their seed to someone let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -547,7 +433,13 @@ extension ConversationVC: confirmStyle: .danger, cancelStyle: .alert_text, onConfirm: { [weak self] _ in - self?.sendAttachments(attachments, with: text, hasPermissionToSendSeed: true, onComplete: onComplete) + self?.sendMessage( + text: text, + attachments: attachments, + linkPreviewDraft: linkPreviewDraft, + quoteModel: quoteModel, + hasPermissionToSendSeed: true + ) } ) ) @@ -561,70 +453,99 @@ extension ConversationVC: self?.snInputView.quoteDraftInfo = nil self?.resetMentions() + self?.scrollToBottom(isAnimated: false) } // 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 threadId: String = self.viewModel.threadData.threadId + let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true) let sentTimestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs() // If this was a message request then approve it approveMessageRequestIfNeeded( for: threadId, - threadVariant: self.viewModel.threadData.threadVariant, + threadVariant: threadVariant, isNewThread: !oldThreadShouldBeVisible, timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting ) - // 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), - expiresInSeconds: try? DisappearingMessagesConfiguration - .select(.durationSeconds) - .filter(id: threadId) - .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) - .asRequest(of: TimeInterval.self) - .fetchOne(db) - ).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?() - } - } + // Optimistically insert the outgoing message (this will trigger a UI update) + self.viewModel.sentMessageBeforeUpdate = true + let optimisticData: ConversationViewModel.OptimisticMessageData = self.viewModel.optimisticallyAppendOutgoingMessage( + text: processedText, + sentTimestampMs: sentTimestampMs, + attachments: attachments, + linkPreviewDraft: linkPreviewDraft, + quoteModel: quoteModel ) + + DispatchQueue.global(qos:.userInitiated).async { + // Generate the quote thumbnail if needed (want this to happen outside of the DBWrite thread as + // this can take up to 0.5s + let quoteThumbnailAttachment: Attachment? = quoteModel?.attachment?.cloneAsQuoteThumbnail() + + // Actually send the message + Storage.shared + .writePublisher { [weak self] db in + // Update the thread to be visible (if it isn't already) + if self?.viewModel.threadData.threadShouldBeVisible == false { + _ = try SessionThread + .filter(id: threadId) + .updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true)) + } + + // Insert the interaction and associated it with the optimistically inserted message so + // we can remove it once the database triggers a UI update + let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db) + self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id) + + // If there is a LinkPreview and it doesn't match an existing one then add it now + if + let linkPreviewDraft: LinkPreviewDraft = linkPreviewDraft, + (try? insertedInteraction.linkPreview.isEmpty(db)) == true + { + try LinkPreview( + url: linkPreviewDraft.urlString, + title: linkPreviewDraft.title, + attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id + ).insert(db) + } + + // If there is a Quote the insert it now + if let interactionId: Int64 = insertedInteraction.id, let quoteModel: QuotedReplyModel = quoteModel { + try Quote( + interactionId: interactionId, + authorId: quoteModel.authorId, + timestampMs: quoteModel.timestampMs, + body: quoteModel.body, + attachmentId: try quoteThumbnailAttachment?.inserted(db).id + ).insert(db) + } + + // Process any attachments + try Attachment.process( + db, + data: optimisticData.attachmentData, + for: insertedInteraction.id + ) + + try MessageSender.send( + db, + interaction: insertedInteraction, + threadId: threadId, + threadVariant: threadVariant + ) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete( + receiveCompletion: { [weak self] _ in + self?.handleMessageSent() + } + ) + } } func handleMessageSent() { @@ -673,9 +594,11 @@ extension ConversationVC: let threadId: String = self.viewModel.threadData.threadId let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true) + let threadIsBlocked: Bool = (self.viewModel.threadData.threadIsBlocked == true) let needsToStartTypingIndicator: Bool = TypingIndicators.didStartTypingNeedsToStart( threadId: threadId, threadVariant: threadVariant, + threadIsBlocked: threadIsBlocked, threadIsMessageRequest: threadIsMessageRequest, direction: .outgoing, timestampMs: SnodeAPI.currentOffsetTimestampMs() @@ -816,7 +739,8 @@ extension ConversationVC: for: cellViewModel, recentEmojis: (self.viewModel.threadData.recentReactionEmoji ?? []).compactMap { EmojiWithSkinTones(rawValue: $0) }, currentUserPublicKey: self.viewModel.threadData.currentUserPublicKey, - currentUserBlindedPublicKey: self.viewModel.threadData.currentUserBlindedPublicKey, + currentUserBlinded15PublicKey: self.viewModel.threadData.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: self.viewModel.threadData.currentUserBlinded25PublicKey, currentUserIsOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin( self.viewModel.threadData.currentUserPublicKey, for: self.viewModel.threadData.openGroupRoomToken, @@ -845,10 +769,7 @@ extension ConversationVC: UIView.animate( withDuration: 0.25, - animations: { - self?.scrollButton.alpha = (self?.getScrollButtonOpacity() ?? 0) - self?.unreadCountView.alpha = (self?.scrollButton.alpha ?? 0) - }, + animations: { self?.updateScrollToBottom() }, completion: { _ in guard let contentOffset: CGPoint = self?.tableView.contentOffset else { return } @@ -900,8 +821,8 @@ extension ConversationVC: ) ), confirmTitle: "modal_download_button_title".localized(), - confirmAccessibilityLabel: "Download media", - cancelAccessibilityLabel: "Don't download media", + confirmAccessibility: Accessibility(identifier: "Download media"), + cancelAccessibility: Accessibility(identifier: "Don't download media"), dismissOnConfirm: false // Custom dismissal logic ) { [weak self] _ in self?.viewModel.trustContact() @@ -1028,16 +949,18 @@ extension ConversationVC: case .textOnlyMessage: if let quote: Quote = cellViewModel.quote { // Scroll to the original quoted message - let maybeOriginalInteractionId: Int64? = Storage.shared.read { db in + let maybeOriginalInteractionInfo: Interaction.TimestampInfo? = Storage.shared.read { db in try quote.originalInteraction - .select(.id) - .asRequest(of: Int64.self) + .select(.id, .timestampMs) + .asRequest(of: Interaction.TimestampInfo.self) .fetchOne(db) } - guard let interactionId: Int64 = maybeOriginalInteractionId else { return } + guard let interactionInfo: Interaction.TimestampInfo = maybeOriginalInteractionInfo else { + return + } - self.scrollToInteractionIfNeeded(with: interactionId, highlight: true) + self.scrollToInteractionIfNeeded(with: interactionInfo, focusBehaviour: .highlight) } else if let linkPreview: LinkPreview = cellViewModel.linkPreview { switch linkPreview.variant { @@ -1096,9 +1019,12 @@ extension ConversationVC: func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) { guard viewModel.threadData.canWrite else { return } - guard SessionId.Prefix(from: sessionId) == .blinded else { + // FIXME: Add in support for starting a thread with a 'blinded25' id + guard SessionId.Prefix(from: sessionId) != .blinded25 else { return } + guard SessionId.Prefix(from: sessionId) == .blinded15 else { Storage.shared.write { db in - try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact) + try SessionThread + .fetchOrCreate(db, id: sessionId, variant: .contact, shouldBeVisible: nil) } let conversationVC: ConversationVC = ConversationVC(threadId: sessionId, threadVariant: .contact) @@ -1124,7 +1050,12 @@ extension ConversationVC: ) return try SessionThread - .fetchOrCreate(db, id: (lookup.sessionId ?? lookup.blindedId), variant: .contact) + .fetchOrCreate( + db, + id: (lookup.sessionId ?? lookup.blindedId), + variant: .contact, + shouldBeVisible: nil + ) .id } @@ -1138,8 +1069,9 @@ extension ConversationVC: guard cellViewModel.reactionInfo?.isEmpty == false && ( - self.viewModel.threadData.threadVariant == .closedGroup || - self.viewModel.threadData.threadVariant == .openGroup + self.viewModel.threadData.threadVariant == .legacyGroup || + self.viewModel.threadData.threadVariant == .group || + self.viewModel.threadData.threadVariant == .community ), let allMessages: [MessageViewModel] = self.viewModel.interactionData .first(where: { $0.model == .messages })? @@ -1200,10 +1132,10 @@ extension ConversationVC: } func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) { - guard cellViewModel.threadVariant == .openGroup else { return } + guard cellViewModel.threadVariant == .community else { return } Storage.shared - .read { db -> Promise in + .readPublisher { db -> (OpenGroupAPI.PreparedSendData, OpenGroupAPI.PendingChange) in guard let openGroup: OpenGroup = try? OpenGroup .fetchOne(db, id: cellViewModel.threadId), @@ -1212,11 +1144,17 @@ extension ConversationVC: .filter(id: cellViewModel.id) .asRequest(of: Int64.self) .fetchOne(db) - else { - return Promise(error: StorageError.objectNotFound) - } + else { throw StorageError.objectNotFound } - let pendingChange = OpenGroupManager + let sendData: OpenGroupAPI.PreparedSendData = try OpenGroupAPI + .preparedReactionDeleteAll( + db, + emoji: emoji, + id: openGroupServerMessageId, + in: openGroup.roomToken, + on: openGroup.server + ) + let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager .addPendingReaction( emoji: emoji, id: openGroupServerMessageId, @@ -1225,44 +1163,52 @@ extension ConversationVC: type: .removeAll ) - return OpenGroupAPI - .reactionDeleteAll( - db, - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server + return (sendData, pendingChange) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .flatMap { sendData, pendingChange in + OpenGroupAPI.send(data: sendData) + .handleEvents( + receiveOutput: { _, response in + OpenGroupManager + .updatePendingChange( + pendingChange, + seqNo: response.seqNo + ) + } ) - .map { _, response in - OpenGroupManager - .updatePendingChange( - pendingChange, - seqNo: response.seqNo - ) + .eraseToAnyPublisher() + } + .sinkUntilComplete( + receiveCompletion: { _ in + Storage.shared.writeAsync { db in + _ = try Reaction + .filter(Reaction.Columns.interactionId == cellViewModel.id) + .filter(Reaction.Columns.emoji == emoji) + .deleteAll(db) } - } - .done { _ in - Storage.shared.writeAsync { db in - _ = try Reaction - .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter(Reaction.Columns.emoji == emoji) - .deleteAll(db) } - } - .retainUntilComplete() + ) } - func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) { - guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else { - return - } - - let threadIsMessageRequest: Bool = (self.viewModel.threadData.threadIsMessageRequest == true) - guard !threadIsMessageRequest else { return } + func react( + _ cellViewModel: MessageViewModel, + with emoji: String, + remove: Bool, + using dependencies: Dependencies = Dependencies() + ) { + guard + self.viewModel.threadData.threadIsMessageRequest != true && ( + cellViewModel.variant == .standardIncoming || + cellViewModel.variant == .standardOutgoing + ) + else { return } // Perform local rate limiting (don't allow more than 20 reactions within 60 seconds) + let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant + let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() - let recentReactionTimestamps: [Int64] = General.cache.wrappedValue.recentReactionTimestamps + let recentReactionTimestamps: [Int64] = dependencies.generalCache.recentReactionTimestamps guard recentReactionTimestamps.count < 20 || @@ -1280,48 +1226,75 @@ extension ConversationVC: return } - General.cache.mutate { + dependencies.mutableGeneralCache.mutate { $0.recentReactionTimestamps = Array($0.recentReactionTimestamps .suffix(19)) .appending(sentTimestamp) } - // Perform the sending logic - Storage.shared.writeAsync( - updates: { db in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: cellViewModel.threadId) else { - return + typealias OpenGroupInfo = ( + pendingReaction: Reaction?, + pendingChange: OpenGroupAPI.PendingChange, + sendData: OpenGroupAPI.PreparedSendData + ) + + /// Perform the sending logic, we generate the pending reaction first in a deferred future closure to prevent the OpenGroup + /// cache from blocking either the main thread or the database write thread + Deferred { + Future { resolver in + guard + threadVariant == .community, + let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, + let openGroupServer: String = cellViewModel.threadOpenGroupServer, + let openGroupPublicKey: String = cellViewModel.threadOpenGroupPublicKey + else { return resolver(Result.success(nil)) } + + // Create the pending change if we have open group info + return resolver(Result.success( + OpenGroupManager.addPendingReaction( + emoji: emoji, + id: serverMessageId, + in: openGroupServer, + on: openGroupPublicKey, + type: (remove ? .remove : .add) + ) + )) + } + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .flatMap { pendingChange -> AnyPublisher<(MessageSender.PreparedSendData?, OpenGroupInfo?), Error> in + Storage.shared.writePublisher { [weak self] db -> (MessageSender.PreparedSendData?, OpenGroupInfo?) in + // Update the thread to be visible (if it isn't already) + if self?.viewModel.threadData.threadShouldBeVisible == false { + _ = try SessionThread + .filter(id: cellViewModel.threadId) + .updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true)) } - // Update the thread to be visible - _ = try SessionThread - .filter(id: thread.id) - .updateAll(db, SessionThread.Columns.shouldBeVisible.set(to: true)) - let pendingReaction: Reaction? = { - if remove { + guard !remove else { return try? Reaction .filter(Reaction.Columns.interactionId == cellViewModel.id) .filter(Reaction.Columns.authorId == cellViewModel.currentUserPublicKey) .filter(Reaction.Columns.emoji == emoji) .fetchOne(db) - } else { - let sortId = Reaction.getSortId( - db, - interactionId: cellViewModel.id, - emoji: emoji - ) - - return Reaction( - interactionId: cellViewModel.id, - serverHash: nil, - timestampMs: sentTimestamp, - authorId: cellViewModel.currentUserPublicKey, - emoji: emoji, - count: 1, - sortId: sortId - ) } + + let sortId: Int64 = Reaction.getSortId( + db, + interactionId: cellViewModel.id, + emoji: emoji + ) + + return Reaction( + interactionId: cellViewModel.id, + serverHash: nil, + timestampMs: sentTimestamp, + authorId: cellViewModel.currentUserPublicKey, + emoji: emoji, + count: 1, + sortId: sortId + ) }() // Update the database @@ -1339,115 +1312,108 @@ extension ConversationVC: Emoji.addRecent(db, emoji: emoji) } - if let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: cellViewModel.threadId), - OpenGroupManager.isOpenGroupSupport(.reactions, on: openGroup.server) - { - // Send reaction to open groups - guard - let openGroupServerMessageId: Int64 = try? Interaction - .select(.openGroupServerMessageId) - .filter(id: cellViewModel.id) - .asRequest(of: Int64.self) - .fetchOne(db) - else { return } - - if remove { - let pendingChange = OpenGroupManager - .addPendingReaction( - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server, - type: .remove - ) - OpenGroupAPI - .reactionDelete( - db, - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server - ) - .map { _, response in - OpenGroupManager - .updatePendingChange( - pendingChange, - seqNo: response.seqNo + switch threadVariant { + case .community: + guard + let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId, + let openGroupServer: String = cellViewModel.threadOpenGroupServer, + let openGroupRoom: String = openGroupRoom, + let pendingChange: OpenGroupAPI.PendingChange = pendingChange, + OpenGroupManager.doesOpenGroupSupport(db, capability: .reactions, on: openGroupServer) + else { throw MessageSenderError.invalidMessage } + + let sendData: OpenGroupAPI.PreparedSendData = try { + guard !remove else { + return try OpenGroupAPI + .preparedReactionDelete( + db, + emoji: emoji, + id: serverMessageId, + in: openGroupRoom, + on: openGroupServer ) + .map { _, response in response.seqNo } } - .catch { [weak self] _ in - OpenGroupManager.removePendingChange(pendingChange) - - self?.handleReactionSentFailure( - pendingReaction, - remove: remove + + return try OpenGroupAPI + .preparedReactionAdd( + db, + emoji: emoji, + id: serverMessageId, + in: openGroupRoom, + on: openGroupServer ) - - } - .retainUntilComplete() - } - else { - let pendingChange = OpenGroupManager - .addPendingReaction( - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server, - type: .add - ) - - OpenGroupAPI - .reactionAdd( - db, - emoji: emoji, - id: openGroupServerMessageId, - in: openGroup.roomToken, - on: openGroup.server - ) - .map { _, response in - OpenGroupManager - .updatePendingChange( - pendingChange, - seqNo: response.seqNo - ) - } - .catch { [weak self] _ in - OpenGroupManager.removePendingChange(pendingChange) - - self?.handleReactionSentFailure( - pendingReaction, - remove: remove + .map { _, response in response.seqNo } + }() + + return (nil, (pendingReaction, pendingChange, sendData)) + + default: + let sendData: MessageSender.PreparedSendData = try MessageSender.preparedSendData( + db, + message: VisibleMessage( + sentTimestamp: UInt64(sentTimestamp), + text: nil, + reaction: VisibleMessage.VMReaction( + timestamp: UInt64(cellViewModel.timestampMs), + publicKey: { + guard cellViewModel.variant == .standardIncoming else { + return cellViewModel.currentUserPublicKey + } + + return cellViewModel.authorId + }(), + emoji: emoji, + kind: (remove ? .remove : .react) ) - } - .retainUntilComplete() - } - } - else { - // Send the actual message - try MessageSender.send( - db, - message: VisibleMessage( - sentTimestamp: UInt64(sentTimestamp), - text: nil, - reaction: VisibleMessage.VMReaction( - timestamp: UInt64(cellViewModel.timestampMs), - publicKey: { - guard cellViewModel.variant == .standardIncoming else { - return cellViewModel.currentUserPublicKey - } - - return cellViewModel.authorId - }(), - emoji: emoji, - kind: (remove ? .remove : .react) - ) - ), - interactionId: cellViewModel.id, - in: thread - ) + ), + to: try Message.Destination + .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant), + namespace: try Message.Destination + .from(db, threadId: cellViewModel.threadId, threadVariant: cellViewModel.threadVariant) + .defaultNamespace, + interactionId: cellViewModel.id + ) + + return (sendData, nil) } } - ) + } + .tryFlatMap { messageSendData, openGroupInfo -> AnyPublisher in + switch (messageSendData, openGroupInfo) { + case (.some(let sendData), _): + return MessageSender.sendImmediate(preparedSendData: sendData) + + case (_, .some(let info)): + return OpenGroupAPI.send(data: info.sendData) + .handleEvents( + receiveOutput: { _, seqNo in + OpenGroupManager + .updatePendingChange( + info.pendingChange, + seqNo: seqNo + ) + }, + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure: + OpenGroupManager.removePendingChange(info.pendingChange) + + self?.handleReactionSentFailure( + info.pendingReaction, + remove: remove + ) + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + + default: throw MessageSenderError.invalidMessage + } + } + .sinkUntilComplete() } func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) { @@ -1557,7 +1523,8 @@ extension ConversationVC: guard let presentingViewController: UIViewController = modal.presentingViewController else { return } - guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: url) else { + + guard let (room, server, publicKey) = SessionUtil.parseCommunity(url: url) else { let errorModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "COMMUNITY_ERROR_GENERIC".localized(), @@ -1570,33 +1537,56 @@ extension ConversationVC: } Storage.shared - .writeAsync { db in + .writePublisher { db in OpenGroupManager.shared.add( db, roomToken: room, server: server, publicKey: publicKey, - isConfigMessage: false + calledFromConfigHandling: false ) } - .done(on: DispatchQueue.main) { _ in - Storage.shared.writeAsync { db in - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...) + .flatMap { successfullyAddedGroup in + OpenGroupManager.shared.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: room, + server: server, + publicKey: publicKey, + calledFromConfigHandling: false + ) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + // If there was a failure then the group will be in invalid state until + // the next launch so remove it (the user will be left on the previous + // screen so can re-trigger the join) + Storage.shared.writeAsync { db in + OpenGroupManager.shared.delete( + db, + openGroupId: OpenGroup.idFor(roomToken: room, server: server), + calledFromConfigHandling: false + ) + } + + // Show the user an error indicating they failed to properly join the group + let errorModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "COMMUNITY_ERROR_GENERIC".localized(), + body: .text(error.localizedDescription), + cancelTitle: "BUTTON_OK".localized(), + cancelStyle: .alert_text + ) + ) + + presentingViewController.present(errorModal, animated: true, completion: nil) + } } - } - .catch(on: DispatchQueue.main) { error in - let errorModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "COMMUNITY_ERROR_GENERIC".localized(), - body: .text(error.localizedDescription), - cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text - ) - ) - - presentingViewController.present(errorModal, animated: true, completion: nil) - } - .retainUntilComplete() + ) } ) ) @@ -1621,8 +1611,8 @@ extension ConversationVC: 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) + let threadVariant: SessionThread.Variant = self?.viewModel.threadData.threadVariant, + let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id) else { return } if @@ -1657,7 +1647,8 @@ extension ConversationVC: try MessageSender.send( db, interaction: interaction, - in: thread, + threadId: threadId, + threadVariant: threadVariant, isSyncMessage: (cellViewModel.state == .failedToSync) ) } @@ -1673,7 +1664,8 @@ extension ConversationVC: attachments: cellViewModel.attachments, linkPreviewAttachment: cellViewModel.linkPreviewAttachment, currentUserPublicKey: cellViewModel.currentUserPublicKey, - currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey + currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey ) guard let quoteDraft: QuotedReplyModel = maybeQuoteDraft else { return } @@ -1687,7 +1679,7 @@ extension ConversationVC: func copy(_ cellViewModel: MessageViewModel) { switch cellViewModel.cellType { - case .typingIndicator, .dateHeader: break + case .typingIndicator, .dateHeader, .unreadMarker: break case .textOnlyMessage: if cellViewModel.body == nil, let linkPreview: LinkPreview = cellViewModel.linkPreview { @@ -1743,45 +1735,51 @@ extension ConversationVC: case .standardOutgoing, .standardIncoming: break } - let threadId: String = self.viewModel.threadData.threadId let threadName: String = self.viewModel.threadData.displayName let userPublicKey: String = getUserHexEncodedPublicKey() // Remote deletion logic - func deleteRemotely(from viewController: UIViewController?, request: Promise, onComplete: (() -> ())?) { + func deleteRemotely(from viewController: UIViewController?, request: AnyPublisher, 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 + Deferred { + Future { resolver in + DispatchQueue.main.async { + ModalActivityIndicatorViewController.present(fromViewController: viewController, canCancel: false) { _ in + resolver(Result.success(())) } - - onComplete?() } } - .retainUntilComplete() + } + .flatMap { _ in request } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { [weak self] result in + switch result { + case .failure: break + case .finished: + // Delete the interaction (and associated data) from the database + Storage.shared.writeAsync { db in + _ = try Interaction + .filter(id: cellViewModel.id) + .deleteAll(db) + } + } + + // Regardless of success we should dismiss and callback + if self?.presentedViewController is ModalActivityIndicatorViewController { + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + } + + onComplete?() + } + ) } // How we delete the message differs depending on the type of thread switch cellViewModel.threadVariant { // Handle open group messages the old way - case .openGroup: + case .community: // 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 ( @@ -1790,7 +1788,7 @@ extension ConversationVC: .filter(id: cellViewModel.id) .asRequest(of: Int64.self) .fetchOne(db), - try OpenGroup.fetchOne(db, id: threadId) + try OpenGroup.fetchOne(db, id: cellViewModel.threadId) ) } @@ -1860,20 +1858,27 @@ extension ConversationVC: // 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 - ) + request: Storage.shared + .readPublisher { db in + try OpenGroupAPI.preparedMessageDelete( + db, + id: openGroupServerMessageId, + in: openGroup.roomToken, + on: openGroup.server + ) + } + .flatMap { OpenGroupAPI.send(data: $0) } .map { _ in () } - } + .eraseToAnyPublisher() ) { [weak self] in self?.showInputAccessoryView() } - case .contact, .closedGroup: + case .contact, .legacyGroup, .group: + let targetPublicKey: String = (cellViewModel.threadVariant == .contact ? + userPublicKey : + cellViewModel.threadId + ) let serverHash: String? = Storage.shared.read { db -> String? in try Interaction .select(.serverHash) @@ -1904,7 +1909,7 @@ extension ConversationVC: .send( db, message: unsendRequest, - threadId: threadId, + threadId: cellViewModel.threadId, interactionId: nil, to: .contact(publicKey: userPublicKey) ) @@ -1927,7 +1932,7 @@ extension ConversationVC: .send( db, message: unsendRequest, - threadId: threadId, + threadId: cellViewModel.threadId, interactionId: nil, to: .contact(publicKey: userPublicKey) ) @@ -1938,7 +1943,7 @@ extension ConversationVC: actionSheet.addAction(UIAlertAction( title: { switch cellViewModel.threadVariant { - case .closedGroup: return "delete_message_for_everyone".localized() + case .legacyGroup, .group: return "delete_message_for_everyone".localized() default: return (cellViewModel.threadId == userPublicKey ? "delete_message_for_me_and_my_devices".localized() : @@ -1949,39 +1954,43 @@ extension ConversationVC: accessibilityIdentifier: "Delete for everyone", style: .destructive ) { [weak self] _ in - deleteRemotely( - from: self, - request: SnodeAPI - .deleteMessage( - publicKey: threadId, - serverHashes: [serverHash] - ) - .map { _ in () } - ) { [weak self] in + let completeServerDeletion = { [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 + threadId: cellViewModel.threadId, + threadVariant: cellViewModel.threadVariant ) } self?.showInputAccessoryView() } + + // We can only delete messages on the server for `contact` and `group` conversations + guard cellViewModel.threadVariant == .contact || cellViewModel.threadVariant == .group else { + return completeServerDeletion() + } + + deleteRemotely( + from: self, + request: SnodeAPI + .deleteMessages( + publicKey: targetPublicKey, + serverHashes: [serverHash] + ) + .map { _ in () } + .eraseToAnyPublisher() + ) { completeServerDeletion() } }) actionSheet.addAction(UIAlertAction.init(title: "TXT_CANCEL_TITLE".localized(), style: .cancel) { [weak self] _ in self?.showInputAccessoryView() }) - self.inputAccessoryView?.isHidden = true - self.inputAccessoryView?.alpha = 0 + self.hideInputAccessoryView() Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view) self.present(actionSheet, animated: true) } @@ -2030,10 +2039,9 @@ extension ConversationVC: } let threadId: String = self.viewModel.threadData.threadId + let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant Storage.shared.writeAsync { db in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } - try MessageSender.send( db, message: DataExtractionNotification( @@ -2041,13 +2049,14 @@ extension ConversationVC: sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) ), interactionId: nil, - in: thread + threadId: threadId, + threadVariant: threadVariant ) } } func ban(_ cellViewModel: MessageViewModel) { - guard cellViewModel.threadVariant == .openGroup else { return } + guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId let modal: ConfirmationModal = ConfirmationModal( @@ -2059,33 +2068,40 @@ extension ConversationVC: cancelStyle: .alert_text, onConfirm: { [weak self] _ in Storage.shared - .read { db -> Promise in + .readPublisher { db -> OpenGroupAPI.PreparedSendData in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - return Promise(error: StorageError.objectNotFound) + throw StorageError.objectNotFound } - return OpenGroupAPI - .userBan( + return try OpenGroupAPI + .preparedUserBan( db, sessionId: cellViewModel.authorId, from: [openGroup.roomToken], on: openGroup.server ) - .map { _ in () } } - .catch(on: DispatchQueue.main) { _ in - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: CommonStrings.errorAlertTitle, - body: .text("context_menu_ban_user_error_alert_message".localized()), - cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) - } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: CommonStrings.errorAlertTitle, + body: .text("context_menu_ban_user_error_alert_message".localized()), + cancelTitle: "BUTTON_OK".localized(), + cancelStyle: .alert_text + ) + ) + self?.present(modal, animated: true) + } + } + ) self?.becomeFirstResponder() }, @@ -2096,7 +2112,7 @@ extension ConversationVC: } func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) { - guard cellViewModel.threadVariant == .openGroup else { return } + guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId let modal: ConfirmationModal = ConfirmationModal( @@ -2108,33 +2124,40 @@ extension ConversationVC: cancelStyle: .alert_text, onConfirm: { [weak self] _ in Storage.shared - .read { db -> Promise in + .readPublisher { db in guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { - return Promise(error: StorageError.objectNotFound) + throw StorageError.objectNotFound } - return OpenGroupAPI - .userBanAndDeleteAllMessages( + return try OpenGroupAPI + .preparedUserBanAndDeleteAllMessages( db, sessionId: cellViewModel.authorId, in: openGroup.roomToken, on: openGroup.server ) - .map { _ in () } } - .catch(on: DispatchQueue.main) { _ in - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: CommonStrings.errorAlertTitle, - body: .text("context_menu_ban_user_error_alert_message".localized()), - cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) - } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: CommonStrings.errorAlertTitle, + body: .text("context_menu_ban_user_error_alert_message".localized()), + cancelTitle: "BUTTON_OK".localized(), + cancelStyle: .alert_text + ) + ) + self?.present(modal, animated: true) + } + } + ) self?.becomeFirstResponder() }, @@ -2259,11 +2282,11 @@ extension ConversationVC: let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String) guard !attachment.hasError else { - return showErrorAlert(for: attachment, onDismiss: nil) + return showErrorAlert(for: attachment) } // Send attachment - sendAttachments([ attachment ], with: "") + sendMessage(text: "", attachments: [attachment]) } func cancelVoiceMessageRecording() { @@ -2285,10 +2308,9 @@ extension ConversationVC: guard self.viewModel.threadData.threadVariant == .contact else { return } let threadId: String = self.viewModel.threadData.threadId + let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant Storage.shared.writeAsync { db in - guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else { return } - try MessageSender.send( db, message: DataExtractionNotification( @@ -2296,22 +2318,22 @@ extension ConversationVC: sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) ), interactionId: nil, - in: thread + threadId: threadId, + threadVariant: threadVariant ) } } // MARK: - Convenience - func showErrorAlert(for attachment: SignalAttachment, onDismiss: (() -> ())?) { + func showErrorAlert(for attachment: SignalAttachment) { let modal: ConfirmationModal = ConfirmationModal( targetView: self.view, info: ConfirmationModal.Info( title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(), body: .text(attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage), cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text, - afterClosed: onDismiss + cancelStyle: .alert_text ) ) self.present(modal, animated: true) @@ -2336,25 +2358,32 @@ extension ConversationVC { timestampMs: Int64 ) { guard threadVariant == .contact else { return } + + let updateNavigationBackStack: () -> Void = { + // Remove the 'MessageRequestsViewController' from the nav hierarchy if present + DispatchQueue.main.async { [weak self] in + if + let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, + let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }), + messageRequestsIndex > 0 + { + var newViewControllers = viewControllers + newViewControllers.remove(at: messageRequestsIndex) + self?.navigationController?.viewControllers = newViewControllers + } + } + } // If the contact doesn't exist then we should create it so we can store the 'isApproved' state // (it'll be updated with correct profile info if they accept the message request so this // shouldn't cause weird behaviours) guard - let 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 - } + let contact: Contact = Storage.shared.read({ db in Contact.fetchOrCreate(db, id: threadId) }), + !contact.isApproved + else { return } - Storage.shared.writeAsync( - updates: { db in + Storage.shared + .writePublisher { 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) @@ -2366,38 +2395,30 @@ extension ConversationVC { sentTimestampMs: UInt64(timestampMs) ), interactionId: nil, - in: thread + threadId: threadId, + threadVariant: threadVariant ) } // Default 'didApproveMe' to true for the person approving the message request - try approvalData.contact - .with( - isApproved: true, - didApproveMe: .update(approvalData.contact.didApproveMe || !isNewThread) + try contact.save(db) + try Contact + .filter(id: contact.id) + .updateAllAndConfig( + db, + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe + .set(to: contact.didApproveMe || !isNewThread) ) - .save(db) - - // Send a sync message with the details of the contact - try MessageSender - .syncConfiguration(db, forceSyncNow: true) - .retainUntilComplete() - }, - completion: { _, _ in - // Remove the 'MessageRequestsViewController' from the nav hierarchy if present - DispatchQueue.main.async { [weak self] in - if - let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, - let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }), - messageRequestsIndex > 0 - { - var newViewControllers = viewControllers - newViewControllers.remove(at: messageRequestsIndex) - self?.navigationController?.viewControllers = newViewControllers - } - } } - ) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { _ in + // Update the UI + updateNavigationBackStack() + } + ) } @objc func acceptMessageRequest() { @@ -2410,81 +2431,49 @@ extension ConversationVC { } @objc func deleteMessageRequest() { - guard self.viewModel.threadData.threadVariant == .contact else { return } - - let threadId: String = self.viewModel.threadData.threadId - let alertVC: UIAlertController = UIAlertController( - title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(), - message: nil, - preferredStyle: .actionSheet + let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions( + [.delete], + for: .trailing, + indexPath: IndexPath(row: 0, section: 0), + tableView: self.tableView, + threadViewModel: self.viewModel.threadData, + viewController: self ) - alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in - // Delete the request - Storage.shared.writeAsync( - updates: { db in - _ = try SessionThread - .filter(id: threadId) - .deleteAll(db) - }, - completion: { db, _ in - DispatchQueue.main.async { [weak self] in - self?.navigationController?.popViewController(animated: true) - } - } - ) - }) - alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil)) - Modal.setupForIPadIfNeeded(alertVC, targetView: self.view) - self.present(alertVC, animated: true, completion: nil) + guard let action: UIContextualAction = actions?.first else { return } + + action.handler(action, self.view, { [weak self] didConfirm in + guard didConfirm else { return } + + self?.stopObservingChanges() + + DispatchQueue.main.async { + self?.navigationController?.popViewController(animated: true) + } + }) } - @objc func block() { - guard self.viewModel.threadData.threadVariant == .contact else { return } - - let threadId: String = self.viewModel.threadData.threadId - let alertVC: UIAlertController = UIAlertController( - title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(), - message: nil, - preferredStyle: .actionSheet + @objc func blockMessageRequest() { + let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions( + [.block], + for: .trailing, + indexPath: IndexPath(row: 0, section: 0), + tableView: self.tableView, + threadViewModel: self.viewModel.threadData, + viewController: self ) - alertVC.addAction(UIAlertAction(title: "BLOCK_LIST_BLOCK_BUTTON".localized(), style: .destructive) { _ in - // Delete the request - Storage.shared.writeAsync( - updates: { db in - // Update the contact - _ = try Contact - .fetchOrCreate(db, id: threadId) - .with( - isApproved: false, - isBlocked: true, - - // Note: We set this to true so the current user will be able to send a - // message to the person who originally sent them the message request in - // the future if they unblock them - didApproveMe: true - ) - .saved(db) - - _ = try SessionThread - .filter(id: threadId) - .deleteAll(db) - - try MessageSender - .syncConfiguration(db, forceSyncNow: true) - .retainUntilComplete() - }, - completion: { db, _ in - DispatchQueue.main.async { [weak self] in - self?.navigationController?.popViewController(animated: true) - } - } - ) - }) - alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil)) - Modal.setupForIPadIfNeeded(alertVC, targetView: self.view) - self.present(alertVC, animated: true, completion: nil) + guard let action: UIContextualAction = actions?.first else { return } + + action.handler(action, self.view, { [weak self] didConfirm in + guard didConfirm else { return } + + self?.stopObservingChanges() + + DispatchQueue.main.async { + self?.navigationController?.popViewController(animated: true) + } + }) } } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 17ab87f7d..895c6a9c7 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -9,11 +9,13 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit -final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { +final class ConversationVC: BaseVC, SessionUtilRespondingViewController, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { private static let loadingHeaderHeight: CGFloat = 40 internal let viewModel: ConversationViewModel - private var dataChangeObservable: DatabaseCancellable? + private var dataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } private var hasLoadedInitialThreadData: Bool = false private var hasLoadedInitialInteractionData: Bool = false private var currentTargetOffset: CGPoint? @@ -25,12 +27,9 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl /// never have disappeared before - this is only needed for value observers since they run asynchronously) private var hasReloadedThreadDataAfterDisappearance: Bool = true - var focusedInteractionId: Int64? + var focusedInteractionInfo: Interaction.TimestampInfo? + var focusBehaviour: ConversationViewModel.FocusBehaviour = .none var shouldHighlightNextScrollToInteraction: Bool = false - var scrollButtonBottomConstraint: NSLayoutConstraint? - var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? - var scrollButtonPendingMessageRequestInfoBottomConstraint: NSLayoutConstraint? - var messageRequestsViewBotomConstraint: NSLayoutConstraint? // Search var isShowingSearchUI = false @@ -40,8 +39,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl var audioRecorder: AVAudioRecorder? var audioTimer: Timer? - private var searchBarWidth: NSLayoutConstraint? - // Context menu var contextMenuWindow: ContextMenuWindow? var contextMenuVC: ContextMenuVC? @@ -129,6 +126,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // MARK: - UI + var scrollButtonBottomConstraint: NSLayoutConstraint? + var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? + var messageRequestsViewBotomConstraint: NSLayoutConstraint? + var messageRequestDescriptionLabelBottomConstraint: NSLayoutConstraint? + lazy var titleView: ConversationTitleView = { let result: ConversationTitleView = ConversationTitleView() let tapGestureRecognizer = UITapGestureRecognizer( @@ -158,10 +160,13 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl ) result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self) result.register(view: DateHeaderCell.self) + result.register(view: UnreadMarkerCell.self) result.register(view: VisibleMessageCell.self) result.register(view: InfoMessageCell.self) result.register(view: TypingIndicatorCell.self) result.register(view: CallMessageCell.self) + result.estimatedSectionHeaderHeight = ConversationVC.loadingHeaderHeight + result.sectionFooterHeight = 0 result.dataSource = self result.delegate = self @@ -181,6 +186,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize) result.set(.height, to: ConversationVC.unreadCountViewSize) result.isHidden = true + result.alpha = 0 return result }() @@ -207,6 +213,37 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl return result }() + + private lazy var emptyStateLabel: UILabel = { + let text: String = String( + format: { + switch (viewModel.threadData.threadIsNoteToSelf, viewModel.threadData.canWrite) { + case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() + case (_, false): return "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() + default: return "CONVERSATION_EMPTY_STATE".localized() + } + }(), + viewModel.threadData.displayName + ) + + let result: UILabel = UILabel() + result.accessibilityLabel = "Empty state label" + result.translatesAutoresizingMaskIntoConstraints = false + result.font = .systemFont(ofSize: Values.verySmallFontSize) + result.attributedText = NSAttributedString(string: text) + .adding( + attributes: [.font: UIFont.boldSystemFont(ofSize: Values.verySmallFontSize)], + range: text.range(of: self.viewModel.threadData.displayName) + .map { NSRange($0, in: text) } + .defaulting(to: NSRange(location: 0, length: 0)) + ) + result.themeTextColor = .textSecondary + result.textAlignment = .center + result.lineBreakMode = .byWordWrapping + result.numberOfLines = 0 + + return result + }() lazy var footerControlsStackView: UIStackView = { let result: UIStackView = UIStackView() @@ -221,12 +258,36 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl return result }() - lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self) - - lazy var messageRequestView: UIView = { + lazy var scrollButton: RoundIconButton = { + let result: RoundIconButton = RoundIconButton( + image: UIImage(named: "ic_chevron_down")? + .withRenderingMode(.alwaysTemplate) + ) { [weak self] in + // 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. + self?.scrollToBottom(isAnimated: true) + } + result.alpha = 0 + + return result + }() + + lazy var messageRequestBackgroundView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false result.themeBackgroundColor = .backgroundPrimary + result.isHidden = messageRequestStackView.isHidden + + return result + }() + + lazy var messageRequestStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.translatesAutoresizingMaskIntoConstraints = false + result.axis = .vertical + result.alignment = .fill + result.distribution = .fill result.isHidden = ( self.viewModel.threadData.threadIsMessageRequest == false || self.viewModel.threadData.threadRequiresApproval == true @@ -234,18 +295,40 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl return result }() + + private lazy var messageRequestDescriptionContainerView: UIView = { + let result: UIView = UIView() + result.translatesAutoresizingMaskIntoConstraints = false + + return result + }() - private let messageRequestDescriptionLabel: UILabel = { + private lazy var messageRequestDescriptionLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false + result.setContentCompressionResistancePriority(.required, for: .vertical) result.font = UIFont.systemFont(ofSize: 12) - result.text = "MESSAGE_REQUESTS_INFO".localized() + result.text = (self.viewModel.threadData.threadRequiresApproval == false ? + "MESSAGE_REQUESTS_INFO".localized() : + "MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized() + ) result.themeTextColor = .textSecondary result.textAlignment = .center result.numberOfLines = 0 return result }() + + private lazy var messageRequestActionStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.translatesAutoresizingMaskIntoConstraints = false + result.axis = .horizontal + result.alignment = .fill + result.distribution = .fill + result.spacing = (UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20) + + return result + }() private lazy var messageRequestAcceptButton: UIButton = { let result: SessionButton = SessionButton(style: .bordered, size: .medium) @@ -260,10 +343,10 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl private lazy var messageRequestDeleteButton: UIButton = { let result: SessionButton = SessionButton(style: .destructive, size: .medium) - result.accessibilityLabel = "Decline message request" + result.accessibilityLabel = "Delete message request" result.isAccessibilityElement = true result.translatesAutoresizingMaskIntoConstraints = false - result.setTitle("TXT_DECLINE_TITLE".localized(), for: .normal) + result.setTitle("TXT_DELETE_TITLE".localized(), for: .normal) result.addTarget(self, action: #selector(deleteMessageRequest), for: .touchUpInside) return result @@ -277,27 +360,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16) result.setTitle("TXT_BLOCK_USER_TITLE".localized(), for: .normal) result.setThemeTitleColor(.danger, for: .normal) - result.addTarget(self, action: #selector(block), for: .touchUpInside) + result.addTarget(self, action: #selector(blockMessageRequest), for: .touchUpInside) + result.isHidden = (self.viewModel.threadData.threadVariant != .contact) return result }() - - private lazy var pendingMessageRequestExplanationLabel: UILabel = { - let result: UILabel = UILabel() - result.translatesAutoresizingMaskIntoConstraints = false - result.setContentCompressionResistancePriority(.required, for: .vertical) - result.font = UIFont.systemFont(ofSize: 12) - result.text = "MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized() - result.themeTextColor = .textSecondary - result.textAlignment = .center - result.numberOfLines = 0 - result.isHidden = ( - !self.messageRequestView.isHidden || - self.viewModel.threadData.threadRequiresApproval == false - ) - - return result - }() // MARK: - Settings @@ -315,8 +382,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // MARK: - Initialization - init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil) { - self.viewModel = ConversationViewModel(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId) + init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo? = nil) { + self.viewModel = ConversationViewModel(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo) Storage.shared.addObserver(viewModel.pagedDataObserver) @@ -342,8 +409,16 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // 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) + updateNavBarButtons( + threadData: nil, + initialVariant: self.viewModel.initialThreadVariant, + initialIsNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf, + initialIsBlocked: (self.viewModel.threadData.threadIsBlocked == true) + ) + titleView.initialSetup( + with: self.viewModel.initialThreadVariant, + isNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf + ) // Constraints view.addSubview(tableView) @@ -351,43 +426,40 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // Message requests view & scroll to bottom view.addSubview(scrollButton) - view.addSubview(messageRequestView) - view.addSubview(pendingMessageRequestExplanationLabel) - - messageRequestView.addSubview(messageRequestBlockButton) - 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) - messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, of: view, withInset: -16) - scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) - scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint - scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) - scrollButtonPendingMessageRequestInfoBottomConstraint = scrollButton.pin(.bottom, to: .top, of: pendingMessageRequestExplanationLabel, withInset: -16) - - messageRequestBlockButton.pin(.top, to: .top, of: messageRequestView, withInset: 10) - messageRequestBlockButton.center(.horizontal, in: messageRequestView) + view.addSubview(emptyStateLabel) + view.addSubview(messageRequestBackgroundView) + view.addSubview(messageRequestStackView) - messageRequestDescriptionLabel.pin(.top, to: .bottom, of: messageRequestBlockButton, withInset: 5) - 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) + emptyStateLabel.pin(.top, to: .top, of: view, withInset: Values.largeSpacing) + emptyStateLabel.pin(.leading, to: .leading, of: view, withInset: Values.veryLargeSpacing) + emptyStateLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.veryLargeSpacing) - messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20) - messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20) - messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20) - messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView) + messageRequestStackView.addArrangedSubview(messageRequestBlockButton) + messageRequestStackView.addArrangedSubview(messageRequestDescriptionContainerView) + messageRequestStackView.addArrangedSubview(messageRequestActionStackView) + messageRequestDescriptionContainerView.addSubview(messageRequestDescriptionLabel) + messageRequestActionStackView.addArrangedSubview(messageRequestAcceptButton) + messageRequestActionStackView.addArrangedSubview(messageRequestDeleteButton) + + scrollButton.pin(.trailing, to: .trailing, of: view, withInset: -20) + messageRequestStackView.pin(.leading, to: .leading, of: view, withInset: 16) + messageRequestStackView.pin(.trailing, to: .trailing, of: view, withInset: -16) + self.messageRequestsViewBotomConstraint = messageRequestStackView.pin(.bottom, to: .bottom, of: view, withInset: -16) + 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: messageRequestStackView, withInset: -4) + + messageRequestDescriptionLabel.pin(.top, to: .top, of: messageRequestDescriptionContainerView, withInset: 4) + messageRequestDescriptionLabel.pin(.leading, to: .leading, of: messageRequestDescriptionContainerView, withInset: 20) + messageRequestDescriptionLabel.pin(.trailing, to: .trailing, of: messageRequestDescriptionContainerView, withInset: -20) + self.messageRequestDescriptionLabelBottomConstraint = messageRequestDescriptionLabel.pin(.bottom, to: .bottom, of: messageRequestDescriptionContainerView, withInset: -20) + messageRequestActionStackView.pin(.top, to: .bottom, of: messageRequestDescriptionContainerView) + messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton) - - pendingMessageRequestExplanationLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40) - pendingMessageRequestExplanationLabel.pin(.right, to: .right, of: messageRequestView, withInset: -40) - pendingMessageRequestExplanationLabel.pin(.bottom, to: .bottom, of: messageRequestView, withInset: -16) + messageRequestBackgroundView.pin(.top, to: .top, of: messageRequestStackView) + messageRequestBackgroundView.pin(.leading, to: .leading, of: view) + messageRequestBackgroundView.pin(.trailing, to: .trailing, of: view) + messageRequestBackgroundView.pin(.bottom, to: .bottom, of: view) // Unread count view view.addSubview(unreadCountView) @@ -429,6 +501,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl name: UIApplication.userDidTakeScreenshotNotification, object: nil ) + + // The first time the view loads we should mark the thread as read (in case it was manually + // marked as unread) - doing this here means if we add a "mark as unread" action within the + // conversation settings then we don't need to worry about the conversation getting marked as + // when when the user returns back through this view controller + self.viewModel.markAsRead(target: .thread, timestampMs: nil) } override func viewWillAppear(_ animated: Bool) { @@ -442,6 +520,16 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + /// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the + /// main thread (we don't currently care if it's still in the nav stack though - so if a user is on a conversation settings screen this should + /// get cleared within `viewWillDisappear`) + /// + /// **Note:** We do this on an async queue because `Atomic` can block if something else is mutating it and we want to avoid + /// the risk of blocking the conversation transition + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + SessionApp.currentlyOpenConversationViewController.mutate { $0 = self } + } + if delayFirstResponder || isShowingSearchUI { delayFirstResponder = false @@ -464,6 +552,16 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + /// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the + /// main thread (we don't currently care if it's still in the nav stack though - so if a user leaves a conversation settings screen we clear + /// it, and if a user moves to a different `ConversationVC` this will get updated to that one within `viewDidAppear`) + /// + /// **Note:** We do this on an async queue because `Atomic` can block if something else is mutating it and we want to avoid + /// the risk of blocking the conversation transition + DispatchQueue.global(qos: .userInitiated).async { + SessionApp.currentlyOpenConversationViewController.mutate { $0 = nil } + } + viewIsDisappearing = true // Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard @@ -481,6 +579,27 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl mediaCache.removeAllObjects() hasReloadedThreadDataAfterDisappearance = false viewIsDisappearing = false + + // If the user just created this thread but didn't send a message then we want to delete the + // "shadow" thread since it's not actually in use (this is to prevent it from taking up database + // space or unintentionally getting synced via libSession in the future) + let threadId: String = viewModel.threadData.threadId + + if + viewModel.threadData.threadIsNoteToSelf == false && + viewModel.threadData.threadShouldBeVisible == false && + !SessionUtil.conversationInConfig( + threadId: threadId, + threadVariant: viewModel.threadData.threadVariant, + visibleOnly: true + ) + { + Storage.shared.writeAsync { db in + _ = try SessionThread // Intentionally use `deleteAll` here instead of `deleteOrLeave` + .filter(id: threadId) + .deleteAll(db) + } + } } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -491,7 +610,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl recoverInputView() - if !isShowingSearchUI { + if !isShowingSearchUI && self.presentedViewController == nil { if !self.isFirstResponder { self.becomeFirstResponder() } @@ -505,16 +624,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl stopObservingChanges() } - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - searchBarWidth?.constant = size.width - 32 - tableView.reloadData() - } - // MARK: - Updating private func startObservingChanges(didReturnFromBackground: Bool = false) { - // Start observing for data changes + guard dataChangeObservable == nil else { return } + dataChangeObservable = Storage.shared.start( viewModel.observableThreadData, onError: { _ in }, @@ -524,7 +638,10 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // and need to swap over to the new one guard let sessionId: String = self?.viewModel.threadData.threadId, - SessionId.Prefix(from: sessionId) == .blinded, + ( + SessionId.Prefix(from: sessionId) == .blinded15 || + SessionId.Prefix(from: sessionId) == .blinded25 + ), let blindedLookup: BlindedIdLookup = Storage.shared.read({ db in try BlindedIdLookup .filter(id: sessionId) @@ -532,8 +649,18 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl }), 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) + // If we don't have an unblinded id then something has gone very wrong so pop to the + // nearest conversation list + let maybeTargetViewController: UIViewController? = self?.navigationController? + .viewControllers + .last(where: { ($0 as? SessionUtilRespondingViewController)?.isConversationList == true }) + + if let targetViewController: UIViewController = maybeTargetViewController { + self?.navigationController?.popToViewController(targetViewController, animated: true) + } + else { + self?.navigationController?.popToRootViewController(animated: true) + } return } @@ -564,16 +691,17 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // 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() + DispatchQueue.global(qos: .background).async { + self?.viewModel.pagedDataObserver?.reload() + } } } } ) } - private func stopObservingChanges() { - // Stop observing database changes - dataChangeObservable?.cancel() + func stopObservingChanges() { + self.dataChangeObservable = nil self.viewModel.onInteractionChange = nil } @@ -615,59 +743,78 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), userCount: updatedThreadData.userCount ) + + // Update the empty state + let text: String = String( + format: { + switch (updatedThreadData.threadIsNoteToSelf, updatedThreadData.canWrite) { + case (true, _): return "CONVERSATION_EMPTY_STATE_NOTE_TO_SELF".localized() + case (_, false): return "CONVERSATION_EMPTY_STATE_READ_ONLY".localized() + default: return "CONVERSATION_EMPTY_STATE".localized() + } + }(), + updatedThreadData.displayName + ) + + emptyStateLabel.attributedText = NSAttributedString(string: text) + .adding( + attributes: [.font: UIFont.boldSystemFont(ofSize: Values.verySmallFontSize)], + range: text.range(of: updatedThreadData.displayName) + .map { NSRange($0, in: text) } + .defaulting(to: NSRange(location: 0, length: 0)) + ) } if initialLoad || + viewModel.threadData.threadVariant != updatedThreadData.threadVariant || + viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked || viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || viewModel.threadData.profile != updatedThreadData.profile { - updateNavBarButtons(threadData: updatedThreadData, initialVariant: viewModel.initialThreadVariant) + updateNavBarButtons( + threadData: updatedThreadData, + initialVariant: viewModel.initialThreadVariant, + initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, + initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) + ) + + messageRequestDescriptionLabel.text = (updatedThreadData.threadRequiresApproval == false ? + "MESSAGE_REQUESTS_INFO".localized() : + "MESSAGE_REQUEST_PENDING_APPROVAL_INFO".localized() + ) let messageRequestsViewWasVisible: Bool = ( - messageRequestView.isHidden == false - ) - let pendingMessageRequestInfoWasVisible: Bool = ( - pendingMessageRequestExplanationLabel.isHidden == false + messageRequestStackView.isHidden == false ) UIView.animate(withDuration: 0.3) { [weak self] in - self?.messageRequestView.isHidden = ( - updatedThreadData.threadIsMessageRequest == false || + self?.messageRequestBlockButton.isHidden = ( + self?.viewModel.threadData.threadVariant != .contact || updatedThreadData.threadRequiresApproval == true ) - self?.pendingMessageRequestExplanationLabel.isHidden = ( - self?.messageRequestView.isHidden == false || + self?.messageRequestActionStackView.isHidden = ( + updatedThreadData.threadRequiresApproval == true + ) + self?.messageRequestStackView.isHidden = ( + updatedThreadData.threadIsMessageRequest == false && updatedThreadData.threadRequiresApproval == false ) + self?.messageRequestBackgroundView.isHidden = (self?.messageRequestStackView.isHidden == true) + self?.messageRequestDescriptionLabelBottomConstraint?.constant = (updatedThreadData.threadRequiresApproval == true ? -4 : -20) self?.scrollButtonMessageRequestsBottomConstraint?.isActive = ( - self?.messageRequestView.isHidden == false - ) - self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive = ( - self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive == false && - self?.pendingMessageRequestExplanationLabel.isHidden == false + self?.messageRequestStackView.isHidden == false ) self?.scrollButtonBottomConstraint?.isActive = ( - self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false && - self?.scrollButtonPendingMessageRequestInfoBottomConstraint?.isActive == false + self?.scrollButtonMessageRequestsBottomConstraint?.isActive == 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 - ) - } - else if pendingMessageRequestInfoWasVisible { - let messageRequestsOffset: CGFloat = ((self?.pendingMessageRequestExplanationLabel.bounds.height ?? 0) + (16 * 2)) + if messageRequestsViewWasVisible != (self?.messageRequestStackView.isHidden == false) { + let messageRequestsOffset: CGFloat = ((self?.messageRequestStackView.bounds.height ?? 0) + 12) let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero) self?.tableView.contentInset = UIEdgeInsets( top: 0, @@ -718,23 +865,36 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl changeset: StagedChangeset<[ConversationViewModel.SectionModel]>, initialLoad: Bool = false ) { + // Determine if we have any messages for the empty state + let hasMessages: Bool = (updatedData + .filter { $0.model == .messages } + .first? + .elements + .isEmpty == 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 { // Need to dispatch async to prevent this from causing glitches in the push animation DispatchQueue.main.async { - self.hasLoadedInitialInteractionData = true self.viewModel.updateInteractionData(updatedData) + // Update the empty state + self.emptyStateLabel.isHidden = hasMessages + UIView.performWithoutAnimation { self.tableView.reloadData() + self.hasLoadedInitialInteractionData = true self.performInitialScrollIfNeeded() } } return } + // Update the empty state + self.emptyStateLabel.isHidden = hasMessages + // Update the ReactionListSheet (if one exists) if let messageUpdates: [MessageViewModel] = updatedData.first(where: { $0.model == .messages })?.elements { self.currentReactionListSheet?.handleInteractionUpdates(messageUpdates) @@ -742,9 +902,34 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // Store the 'sentMessageBeforeUpdate' state locally let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate + let onlyReplacedOptimisticUpdate: Bool = { + // Replacing an optimistic update means making a delete and an insert, which will be done + // as separate changes at the same positions + guard + changeset.count > 1 && + changeset[changeset.count - 2].elementDeleted == changeset[changeset.count - 1].elementInserted + else { return false } + + let deletedModels: [MessageViewModel] = changeset[changeset.count - 2] + .elementDeleted + .map { self.viewModel.interactionData[$0.section].elements[$0.element] } + let insertedModels: [MessageViewModel] = changeset[changeset.count - 1] + .elementInserted + .map { updatedData[$0.section].elements[$0.element] } + + // Make sure all the deleted models were optimistic updates, the inserted models were not + // optimistic updates and they have the same timestamps + return ( + deletedModels.map { $0.id }.asSet() == [MessageViewModel.optimisticUpdateId] && + insertedModels.map { $0.id }.asSet() != [MessageViewModel.optimisticUpdateId] && + deletedModels.map { $0.timestampMs }.asSet() == insertedModels.map { $0.timestampMs }.asSet() + ) + }() let wasOnlyUpdates: Bool = ( - changeset.count == 1 && - changeset[0].elementUpdated.count == changeset[0].changeCount + onlyReplacedOptimisticUpdate || ( + changeset.count == 1 && + changeset[0].elementUpdated.count == changeset[0].changeCount + ) ) self.viewModel.sentMessageBeforeUpdate = false @@ -756,11 +941,20 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl 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 + // If we just sent a message then we want to jump to the bottom of the conversation instantly + if didSendMessageBeforeUpdate { + // We need to dispatch to the next run loop because it seems trying to scroll immediately after + // triggering a 'reloadData' doesn't work + DispatchQueue.main.async { [weak self] in + self?.tableView.layoutIfNeeded() + self?.scrollToBottom(isAnimated: false) + + // 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?.scrollButton.alpha = 0 + self?.unreadCountView.alpha = 0 + } + } return } @@ -809,7 +1003,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl .firstIndex(where: { item -> Bool in // Since the first item is probably a `DateHeaderCell` (which would likely // be removed when inserting items above it) we check if the id matches - // either the first or second item let messages: [MessageViewModel] = self.viewModel .interactionData[oldSectionIndex] .elements @@ -853,8 +1046,8 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // 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 + if let focusedInteractionInfo: Interaction.TimestampInfo = self.focusedInteractionInfo { + // If we had a focusedInteractionInfo then scroll to it (and hide the search // result bar loading indicator) let delay: DispatchTime = (didSwapAllContent ? .now() : @@ -864,9 +1057,9 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl DispatchQueue.main.asyncAfter(deadline: delay) { [weak self] in self?.searchController.resultsBar.stopLoading() self?.scrollToInteractionIfNeeded( - with: focusedInteractionId, - isAnimated: true, - highlight: (self?.shouldHighlightNextScrollToInteraction == true) + with: focusedInteractionInfo, + focusBehaviour: (self?.shouldHighlightNextScrollToInteraction == true ? .highlight : .none), + isAnimated: true ) if wasLoadingMore { @@ -893,8 +1086,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } else { // Need to update the scroll button alpha in case new messages were added but we didn't scroll - self.scrollButton.alpha = self.getScrollButtonOpacity() - self.unreadCountView.alpha = self.scrollButton.alpha + self.updateScrollToBottom() } return } @@ -936,15 +1128,15 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl self?.tableView.contentOffset.y += oldCellTopOffset } - if let focusedInteractionId: Int64 = self?.focusedInteractionId { + if let focusedInteractionInfo: Interaction.TimestampInfo = self?.focusedInteractionInfo { DispatchQueue.main.async { [weak self] in - // If we had a focusedInteractionId then scroll to it (and hide the search + // If we had a focusedInteractionInfo then scroll to it (and hide the search // result bar loading indicator) self?.searchController.resultsBar.stopLoading() self?.scrollToInteractionIfNeeded( - with: focusedInteractionId, - isAnimated: true, - highlight: (self?.shouldHighlightNextScrollToInteraction == true) + with: focusedInteractionInfo, + focusBehaviour: (self?.shouldHighlightNextScrollToInteraction == true ? .highlight : .none), + isAnimated: true ) } } @@ -956,15 +1148,15 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl ) } else if wasLoadingMore { - if let focusedInteractionId: Int64 = self.focusedInteractionId { + if let focusedInteractionInfo: Interaction.TimestampInfo = self.focusedInteractionInfo { DispatchQueue.main.async { [weak self] in - // If we had a focusedInteractionId then scroll to it (and hide the search + // If we had a focusedInteractionInfo then scroll to it (and hide the search // result bar loading indicator) self?.searchController.resultsBar.stopLoading() self?.scrollToInteractionIfNeeded( - with: focusedInteractionId, - isAnimated: true, - highlight: (self?.shouldHighlightNextScrollToInteraction == true) + with: focusedInteractionInfo, + focusBehaviour: (self?.shouldHighlightNextScrollToInteraction == true ? .highlight : .none), + isAnimated: true ) // Complete page loading @@ -1004,15 +1196,18 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // 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) + if let focusedInteractionInfo: Interaction.TimestampInfo = self.viewModel.focusedInteractionInfo { + self.scrollToInteractionIfNeeded( + with: focusedInteractionInfo, + focusBehaviour: self.viewModel.focusBehaviour, + isAnimated: false + ) } else { self.scrollToBottom(isAnimated: false) } - self.scrollButton.alpha = self.getScrollButtonOpacity() - self.unreadCountView.alpha = self.scrollButton.alpha + self.updateScrollToBottom() self.hasPerformedInitialScroll = true // Now that the data has loaded we need to check if either of the "load more" sections are @@ -1024,7 +1219,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } private func autoLoadNextPageIfNeeded() { - guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } + guard + self.hasLoadedInitialInteractionData && + !self.isAutoLoadingNextPage && + !self.isLoadingMore + else { return } self.isAutoLoadingNextPage = true @@ -1064,7 +1263,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } } - func updateNavBarButtons(threadData: SessionThreadViewModel?, initialVariant: SessionThread.Variant) { + func updateNavBarButtons( + threadData: SessionThreadViewModel?, + initialVariant: SessionThread.Variant, + initialIsNoteToSelf: Bool, + initialIsBlocked: Bool + ) { navigationItem.hidesBackButton = isShowingSearchUI if isShowingSearchUI { @@ -1072,6 +1276,13 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl navigationItem.rightBarButtonItems = [] } else { + let shouldHaveCallButton: Bool = ( + SessionCall.isEnabled && + (threadData?.threadVariant ?? initialVariant) == .contact && + (threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false && + (threadData?.threadIsBlocked ?? initialIsBlocked) == false + ) + guard let threadData: SessionThreadViewModel = threadData, ( @@ -1094,7 +1305,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl ) ) ), - (initialVariant == .contact ? + (shouldHaveCallButton ? UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))) : nil ) @@ -1104,15 +1315,15 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl switch threadData.threadVariant { case .contact: - let profilePictureView = ProfilePictureView() - profilePictureView.size = Values.verySmallProfilePictureSize + let profilePictureView = ProfilePictureView(size: .navigation) profilePictureView.update( publicKey: threadData.threadId, // Contact thread uses the contactId + threadVariant: threadData.threadVariant, + customImageData: nil, profile: threadData.profile, - threadVariant: threadData.threadVariant + additionalProfile: nil ) - profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button - profilePictureView.set(.height, to: Values.verySmallProfilePictureSize) + profilePictureView.customWidth = (44 - 16) // Width of the standard back button let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings)) profilePictureView.addGestureRecognizer(tapGestureRecognizer) @@ -1121,19 +1332,20 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl settingsButtonItem.accessibilityLabel = "More options" settingsButtonItem.isAccessibilityElement = true - if SessionCall.isEnabled && !threadData.threadIsNoteToSelf { + if shouldHaveCallButton { let callButton = UIBarButtonItem( image: UIImage(named: "Phone"), style: .plain, target: self, action: #selector(startCall) ) - callButton.accessibilityLabel = "Call button" + callButton.accessibilityLabel = "Call" + callButton.isAccessibilityElement = true navigationItem.rightBarButtonItems = [settingsButtonItem, callButton] } else { - navigationItem.rightBarButtonItem = settingsButtonItem + navigationItem.rightBarButtonItems = [settingsButtonItem] } default: @@ -1164,7 +1376,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl // 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 { + if messageRequestStackView.bounds.height <= CGFloat.leastNonzeroMagnitude { hasDoneLayout = false UIView.performWithoutAnimation { @@ -1173,25 +1385,21 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY) - let messageRequestsOffset: CGFloat = (messageRequestView.isHidden ? 0 : messageRequestView.bounds.height + 16) - let pendingMessageRequestsOffset: CGFloat = (pendingMessageRequestExplanationLabel.isHidden ? 0 : (pendingMessageRequestExplanationLabel.bounds.height + (16 * 2))) + let messageRequestsOffset: CGFloat = (messageRequestStackView.isHidden ? 0 : messageRequestStackView.bounds.height + 12) let oldContentInset: UIEdgeInsets = tableView.contentInset let newContentInset: UIEdgeInsets = UIEdgeInsets( top: 0, leading: 0, - bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset + pendingMessageRequestsOffset), + bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset), trailing: 0 ) 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?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12) + self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12) self?.tableView.contentInset = newContentInset self?.tableView.contentOffset.y = newContentOffsetY - - let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0) - self?.scrollButton.alpha = scrollButtonOpacity - self?.unreadCountView.alpha = scrollButtonOpacity + self?.updateScrollToBottom() self?.view.setNeedsLayout() self?.view.layoutIfNeeded() @@ -1231,12 +1439,9 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl delay: 0, options: options, 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?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12) + self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12) + self?.updateScrollToBottom() self?.view.setNeedsLayout() self?.view.layoutIfNeeded() @@ -1349,14 +1554,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } // MARK: - UITableViewDelegate - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension - } - - 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] @@ -1405,11 +1602,22 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl 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) + let lastInteractionInfo: Interaction.TimestampInfo = { + guard + let interactionId: Int64 = self.viewModel.threadData.interactionId, + let timestampMs: Int64 = self.viewModel.threadData.interactionTimestampMs + else { + return Interaction.TimestampInfo( + id: messages[messages.count - 1].id, + timestampMs: messages[messages.count - 1].timestampMs + ) + } + + return Interaction.TimestampInfo(id: interactionId, timestampMs: timestampMs) + }() self.scrollToInteractionIfNeeded( - with: lastInteractionId, + with: lastInteractionInfo, position: .bottom, isJumpingToLastInteraction: true, isAnimated: true @@ -1417,15 +1625,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl return } - // Note: In this case we need to force a tableView layout to ensure updating the - // scroll position has the correct offset (otherwise there are some cases where - // the screen will jump up - eg. when sending a reply while the soft keyboard - // is visible) - UIView.performWithoutAnimation { - self.tableView.setNeedsLayout() - self.tableView.layoutIfNeeded() - } - let targetIndexPath: IndexPath = IndexPath( row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1), section: messagesSectionIndex @@ -1436,7 +1635,10 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl animated: isAnimated ) - self.viewModel.markAsRead(beforeInclusive: nil) + self.viewModel.markAsRead( + target: .threadAndInteractions(interactionsBeforeInclusive: nil), + timestampMs: nil + ) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { @@ -1448,55 +1650,31 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } func scrollViewDidScroll(_ scrollView: UIScrollView) { - self.scrollButton.alpha = self.getScrollButtonOpacity() - self.unreadCountView.alpha = self.scrollButton.alpha + self.updateScrollToBottom() - // We want to mark messages as read while we scroll, so grab the newest message and mark - // everything older as read - // - // Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance - // the table content appears above the input view - let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing)) + // The initial scroll can trigger this logic but we already mark the initially focused message + // as read so don't run the below until the user actually scrolls after the initial layout + guard self.didFinishInitialLayout else { return } - if - let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows, - let messagesSection: Int = visibleIndexPaths - .first(where: { self.viewModel.interactionData[$0.section].model == .messages })? - .section, - let newestCellViewModel: MessageViewModel = visibleIndexPaths - .sorted() - .filter({ $0.section == messagesSection }) - .compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in - guard let frame: CGRect = tableView.cellForRow(at: indexPath)?.frame else { - return nil - } - - return ( - view.convert(frame, from: tableView), - self.viewModel.interactionData[indexPath.section].elements[indexPath.row] - ) - }) - // Exclude messages that are partially off the bottom of the screen - .filter({ $0.frame.maxY <= tableVisualBottom }) - .last? - .cellViewModel - { - self.viewModel.markAsRead(beforeInclusive: newestCellViewModel.id) - } + self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: nil) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { guard - let focusedInteractionId: Int64 = self.focusedInteractionId, + let focusedInteractionInfo: Interaction.TimestampInfo = self.focusedInteractionInfo, self.shouldHighlightNextScrollToInteraction else { - self.focusedInteractionId = nil + self.focusedInteractionInfo = nil + self.focusBehaviour = .none self.shouldHighlightNextScrollToInteraction = false return } + let behaviour: ConversationViewModel.FocusBehaviour = self.focusBehaviour + DispatchQueue.main.async { [weak self] in - self?.highlightCellIfNeeded(interactionId: focusedInteractionId) + self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: focusedInteractionInfo) + self?.highlightCellIfNeeded(interactionId: focusedInteractionInfo.id, behaviour: behaviour) } } @@ -1507,12 +1685,29 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl unreadCountLabel.font = .boldSystemFont(ofSize: fontSize) unreadCountView.isHidden = (unreadCount == 0) } - - func getScrollButtonOpacity() -> CGFloat { - let contentOffsetY = tableView.contentOffset.y + + public func updateScrollToBottom(force: Bool = false) { + // Don't update the scroll button until we have actually setup the initial scroll position to avoid + // any odd flickering or incorrect appearance + guard self.didFinishInitialLayout || force else { return } + + // If we have a 'loadNewer' item in the interaction data then there are subsequent pages and the + // 'scrollToBottom' actions should always be visible to allow the user to jump to the bottom (without + // this the button will fade out as the user gets close to the bottom of the current page) + guard !self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) else { + self.scrollButton.alpha = 1 + self.unreadCountView.alpha = 1 + return + } + + // Calculate the target opacity for the scroll button + let contentOffsetY: CGFloat = tableView.contentOffset.y let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude) let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold) - return max(0, min(1, a * x)) + let targetOpacity: CGFloat = max(0, min(1, a * x)) + + self.scrollButton.alpha = targetOpacity + self.unreadCountView.alpha = targetOpacity } // MARK: - Search @@ -1542,7 +1737,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl searchBar.sizeToFit() searchBar.layoutMargins = UIEdgeInsets.zero searchBarContainer.set(.height, to: 44) - searchBarWidth = searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32) + searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32) searchBarContainer.addSubview(searchBar) navigationItem.titleView = searchBarContainer @@ -1564,7 +1759,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } // Nav bar buttons - updateNavBarButtons(threadData: self.viewModel.threadData, initialVariant: viewModel.initialThreadVariant) + updateNavBarButtons( + threadData: viewModel.threadData, + initialVariant: viewModel.initialThreadVariant, + initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, + initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) + ) // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. @@ -1599,7 +1799,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl @objc func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView - updateNavBarButtons(threadData: self.viewModel.threadData, initialVariant: viewModel.initialThreadVariant) + updateNavBarButtons( + threadData: viewModel.threadData, + initialVariant: viewModel.initialThreadVariant, + initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, + initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) + ) searchController.uiSearchController.stubbableSearchBar.stubbedNextResponder = nil becomeFirstResponder() @@ -1610,26 +1815,25 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl hideSearchUI() } - func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) { + func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Interaction.TimestampInfo]?, 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 conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo interactionInfo: Interaction.TimestampInfo) { + scrollToInteractionIfNeeded(with: interactionInfo, focusBehaviour: .highlight) } func scrollToInteractionIfNeeded( - with interactionId: Int64, + with interactionInfo: Interaction.TimestampInfo, + focusBehaviour: ConversationViewModel.FocusBehaviour = .none, position: UITableView.ScrollPosition = .middle, isJumpingToLastInteraction: Bool = false, - isAnimated: Bool = true, - highlight: Bool = false + isAnimated: Bool = true ) { // Store the info incase we need to load more data (call will be re-triggered) - self.focusedInteractionId = interactionId - self.shouldHighlightNextScrollToInteraction = highlight - self.viewModel.markAsRead(beforeInclusive: interactionId) + self.focusedInteractionInfo = interactionInfo + self.shouldHighlightNextScrollToInteraction = (focusBehaviour == .highlight) // Ensure the target interaction has been loaded guard @@ -1637,7 +1841,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl .firstIndex(where: { $0.model == .messages }), let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex] .elements - .firstIndex(where: { $0.id == interactionId }) + .firstIndex(where: { $0.id == interactionInfo.id }) else { // If not the make sure we have finished the initial layout before trying to // load the up until the specified interaction @@ -1649,13 +1853,13 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl DispatchQueue.global(qos: .userInitiated).async { [weak self] in if isJumpingToLastInteraction { self?.viewModel.pagedDataObserver?.load(.jumpTo( - id: interactionId, + id: interactionInfo.id, paddingForInclusive: 5 )) } else { self?.viewModel.pagedDataObserver?.load(.untilInclusive( - id: interactionId, + id: interactionInfo.id, padding: 5 )) } @@ -1663,35 +1867,62 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl return } - // Note: If the tableView needs to layout then we should do it first without an animation - // to prevent an annoying issue where the screen jumps slightly after the scroll completes - UIView.performWithoutAnimation { - self.tableView.layoutIfNeeded() - } - - let targetIndexPath: IndexPath = IndexPath( - row: targetMessageIndex, - section: messageSectionIndex - ) + // If it's before the initial layout and the index before the target is an 'UnreadMarker' then + // we should scroll to that instead (will be better UX) + let targetIndexPath: IndexPath = { + guard + !self.didFinishInitialLayout && + targetMessageIndex > 0 && + self.viewModel.interactionData[messageSectionIndex] + .elements[targetMessageIndex - 1] + .cellType == .unreadMarker + else { + return IndexPath( + row: targetMessageIndex, + section: messageSectionIndex + ) + } + + return IndexPath( + row: (targetMessageIndex - 1), + section: messageSectionIndex + ) + }() + let targetPosition: UITableView.ScrollPosition = { + guard position == .middle else { return position } + + // Make sure the target cell isn't too large for the screen (if it is then we want to scroll + // it to the top rather than the middle + let cellSize: CGSize = self.tableView( + tableView, + cellForRowAt: targetIndexPath + ).systemLayoutSizeFitting(view.bounds.size) + + guard cellSize.height > tableView.frame.size.height else { return position } + + return .top + }() // If we aren't animating or aren't highlighting then everything can be run immediately - guard isAnimated && highlight else { + guard isAnimated else { self.tableView.scrollToRow( at: targetIndexPath, - at: position, + at: targetPosition, animated: (self.didFinishInitialLayout && isAnimated) ) - // 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) - } + // If we haven't finished the initial layout then we want to delay the highlight/markRead slightly + // so it doesn't look buggy with the push transition and we know for sure the correct visible cells + // have been loaded + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in + self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo) + self?.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour) + self?.updateScrollToBottom(force: true) } self.shouldHighlightNextScrollToInteraction = false - self.focusedInteractionId = nil + self.focusedInteractionInfo = nil + self.focusBehaviour = .none return } @@ -1702,16 +1933,70 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl let targetRect: CGRect = self.tableView.rectForRow(at: targetIndexPath) guard !self.tableView.bounds.contains(targetRect) else { - self.highlightCellIfNeeded(interactionId: interactionId) + self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo) + self.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour) return } - self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: true) + self.tableView.scrollToRow(at: targetIndexPath, at: targetPosition, animated: true) } - func highlightCellIfNeeded(interactionId: Int64) { + func markFullyVisibleAndOlderCellsAsRead(interactionInfo: Interaction.TimestampInfo?) { + // We want to mark messages as read on load and while we scroll, so grab the newest message and mark + // everything older as read + // + // Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance + // the table content appears above the input view + let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing)) + + guard + let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows, + let messagesSection: Int = visibleIndexPaths + .first(where: { self.viewModel.interactionData[$0.section].model == .messages })? + .section, + let newestCellViewModel: MessageViewModel = visibleIndexPaths + .sorted() + .filter({ $0.section == messagesSection }) + .compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in + guard let cell: VisibleMessageCell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell else { + return nil + } + + return ( + view.convert(cell.frame, from: tableView), + self.viewModel.interactionData[indexPath.section].elements[indexPath.row] + ) + }) + // Exclude messages that are partially off the bottom of the screen + .filter({ $0.frame.maxY <= tableVisualBottom }) + .last? + .cellViewModel + else { + // If we weren't able to get any visible cells for some reason then we should fall back to + // marking the provided interactionInfo as read just in case + if let interactionInfo: Interaction.TimestampInfo = interactionInfo { + self.viewModel.markAsRead( + target: .threadAndInteractions(interactionsBeforeInclusive: interactionInfo.id), + timestampMs: interactionInfo.timestampMs + ) + } + return + } + + // Mark all interactions before the newest entirely-visible one as read + self.viewModel.markAsRead( + target: .threadAndInteractions(interactionsBeforeInclusive: newestCellViewModel.id), + timestampMs: newestCellViewModel.timestampMs + ) + } + + func highlightCellIfNeeded(interactionId: Int64, behaviour: ConversationViewModel.FocusBehaviour) { self.shouldHighlightNextScrollToInteraction = false - self.focusedInteractionId = nil + self.focusedInteractionInfo = nil + self.focusBehaviour = .none + + // Only trigger the highlight if that's the desired behaviour + guard behaviour == .highlight else { return } // Trigger on the next run loop incase we are still finishing some other animation DispatchQueue.main.async { @@ -1722,4 +2007,10 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl .highlight() } } + + // MARK: - SessionUtilRespondingViewController + + func isConversation(in threadIds: [String]) -> Bool { + return threadIds.contains(self.viewModel.threadData.threadId) + } } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 65f4ecec4..425db2210 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import DifferenceKit import SessionMessagingKit @@ -9,6 +10,13 @@ import SessionUtilitiesKit public class ConversationViewModel: OWSAudioPlayerDelegate { public typealias SectionModel = ArraySection + // MARK: - FocusBehaviour + + public enum FocusBehaviour { + case none + case highlight + } + // MARK: - Action public enum Action { @@ -34,7 +42,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { 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 let focusedInteractionInfo: Interaction.TimestampInfo? // Note: This is used for global search + public let focusBehaviour: FocusBehaviour + private let initialUnreadInteractionId: Int64? + private let markAsReadTrigger: PassthroughSubject<(SessionThreadViewModel.ReadTarget, Int64?), Never> = PassthroughSubject() + private var markAsReadPublisher: AnyPublisher? public lazy var blockedBannerMessage: String = { switch self.threadData.threadVariant { @@ -52,66 +64,93 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Initialization - init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) { + init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) { typealias InitialData = ( - targetInteractionId: Int64?, + currentUserPublicKey: String, + initialUnreadInteractionInfo: Interaction.TimestampInfo?, + threadIsBlocked: Bool, currentUserIsClosedGroupMember: Bool?, openGroupPermissions: OpenGroup.Permissions?, - blindedKey: String? + blinded15Key: String?, + blinded25Key: String? ) let initialData: InitialData? = Storage.shared.read { db -> InitialData in let interaction: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - // If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest + // If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest // unread interaction and start focused around that one - let targetInteractionId: Int64? = (focusedInteractionId != nil ? focusedInteractionId : - try Interaction - .select(.id) - .filter(interaction[.wasRead] == false) - .filter(interaction[.threadId] == threadId) - .order(interaction[.timestampMs].asc) - .asRequest(of: Int64.self) + let initialUnreadInteractionInfo: Interaction.TimestampInfo? = try Interaction + .select(.id, .timestampMs) + .filter(interaction[.wasRead] == false) + .filter(interaction[.threadId] == threadId) + .order(interaction[.timestampMs].asc) + .asRequest(of: Interaction.TimestampInfo.self) + .fetchOne(db) + let threadIsBlocked: Bool = (threadVariant != .contact ? false : + try Contact + .filter(id: threadId) + .select(.isBlocked) + .asRequest(of: Bool.self) .fetchOne(db) + .defaulting(to: false) ) - let currentUserIsClosedGroupMember: Bool? = (threadVariant != .closedGroup ? nil : - try GroupMember + let currentUserIsClosedGroupMember: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil : + GroupMember .filter(groupMember[.groupId] == threadId) - .filter(groupMember[.profileId] == getUserHexEncodedPublicKey(db)) + .filter(groupMember[.profileId] == currentUserPublicKey) .filter(groupMember[.role] == GroupMember.Role.standard) .isNotEmpty(db) ) - let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .openGroup ? nil : + let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .community ? nil : try OpenGroup .filter(id: threadId) .select(.permissions) .asRequest(of: OpenGroup.Permissions.self) .fetchOne(db) ) - let blindedKey: String? = SessionThread.getUserHexEncodedBlindedKey( + let blinded15Key: String? = SessionThread.getUserHexEncodedBlindedKey( db, threadId: threadId, - threadVariant: threadVariant + threadVariant: threadVariant, + blindingPrefix: .blinded15 + ) + let blinded25Key: String? = SessionThread.getUserHexEncodedBlindedKey( + db, + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded25 ) return ( - targetInteractionId, + currentUserPublicKey, + initialUnreadInteractionInfo, + threadIsBlocked, currentUserIsClosedGroupMember, openGroupPermissions, - blindedKey + blinded15Key, + blinded25Key ) } self.threadId = threadId self.initialThreadVariant = threadVariant - self.focusedInteractionId = initialData?.targetInteractionId + self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) + self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight) + self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id self.threadData = SessionThreadViewModel( threadId: threadId, threadVariant: threadVariant, + threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId), + threadIsBlocked: initialData?.threadIsBlocked, currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember, openGroupPermissions: initialData?.openGroupPermissions - ).populatingCurrentUserBlindedKey(currentUserBlindedPublicKeyForThisThread: initialData?.blindedKey) + ).populatingCurrentUserBlindedKeys( + currentUserBlinded15PublicKeyForThisThread: initialData?.blinded15Key, + currentUserBlinded25PublicKeyForThisThread: initialData?.blinded25Key + ) self.pagedDataObserver = nil // Note: Since this references self we need to finish initializing before setting it, we @@ -120,23 +159,21 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // distinct stutter) self.pagedDataObserver = self.setupPagedObserver( for: threadId, - userPublicKey: getUserHexEncodedPublicKey(), - blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey( - threadId: threadId, - threadVariant: threadVariant - ) + userPublicKey: (initialData?.currentUserPublicKey ?? getUserHexEncodedPublicKey()), + blinded15PublicKey: initialData?.blinded15Key, + blinded25PublicKey: initialData?.blinded25Key ) // Run the initial query on a background thread so we don't block the push transition DispatchQueue.global(qos: .userInitiated).async { [weak self] in - // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query + // If we don't have a `initialFocusedInfo` then default to `.pageBefore` (it'll query // from a `0` offset) - guard let initialFocusedId: Int64 = initialData?.targetInteractionId else { + guard let initialFocusedInfo: Interaction.TimestampInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) else { self?.pagedDataObserver?.load(.pageBefore) return } - self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId)) + self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedInfo.id)) } } @@ -155,9 +192,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { /// 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) + public typealias ThreadObservation = ValueObservation>>> + public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId) - private func setupObservableThreadData(for threadId: String) -> ValueObservation>> { + private func setupObservableThreadData(for threadId: String) -> ThreadObservation { return ValueObservation .trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in let userPublicKey: String = getUserHexEncodedPublicKey(db) @@ -169,13 +207,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { return threadViewModel .map { $0.with(recentReactionEmoji: recentReactionEmoji) } .map { viewModel -> SessionThreadViewModel in - viewModel.populatingCurrentUserBlindedKey( + viewModel.populatingCurrentUserBlindedKeys( db, - currentUserBlindedPublicKeyForThisThread: self?.threadData.currentUserBlindedPublicKey + currentUserBlinded15PublicKeyForThisThread: self?.threadData.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKeyForThisThread: self?.threadData.currentUserBlinded25PublicKey ) } } .removeDuplicates() + .handleEvents(didFail: { SNLog("[ConversationViewModel] Observation failed with error: \($0)") }) } public func updateThreadData(_ updatedData: SessionThreadViewModel) { @@ -184,7 +224,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Interaction Data - private var lastInteractionIdMarkedAsRead: Int64? + private var lastInteractionIdMarkedAsRead: Int64? = nil + private var lastInteractionTimestampMsMarkedAsRead: Int64 = 0 public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? public private(set) var interactionData: [SectionModel] = [] public private(set) var reactionExpandedInteractionIds: Set = [] @@ -194,14 +235,25 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { 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], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges { - onInteractionChange?(unobservedInteractionDataChanges.0, unobservedInteractionDataChanges.1) + if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges { + let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onInteractionChange + + switch Thread.isMainThread { + case true: performChange?(changes.0, changes.1) + case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) } + } + self.unobservedInteractionDataChanges = nil } } } - private func setupPagedObserver(for threadId: String, userPublicKey: String, blindedPublicKey: String?) -> PagedDatabaseObserver { + private func setupPagedObserver( + for threadId: String, + userPublicKey: String, + blinded15PublicKey: String?, + blinded25PublicKey: String? + ) -> PagedDatabaseObserver { return PagedDatabaseObserver( pagedTable: Interaction.self, pageSize: ConversationViewModel.pageSize, @@ -220,7 +272,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { let interaction: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])") + return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])") }() ), PagedData.ObservedChanges( @@ -230,7 +282,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { let interaction: TypedTableAlias = TypedTableAlias() let profile: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])") + return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])") }() ), PagedData.ObservedChanges( @@ -249,7 +301,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { orderSQL: MessageViewModel.orderSQL, dataQuery: MessageViewModel.baseQuery( userPublicKey: userPublicKey, - blindedPublicKey: blindedPublicKey, + blinded15PublicKey: blinded15PublicKey, + blinded25PublicKey: blinded25PublicKey, orderSQL: MessageViewModel.orderSQL, groupSQL: MessageViewModel.groupSQL ), @@ -293,22 +346,39 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { ) ], onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + self?.resolveOptimisticUpdates(with: updatedData) + PagedData.processAndTriggerUpdates( - updatedData: self?.process(data: updatedData, for: updatedPageInfo), + updatedData: self?.process( + data: updatedData, + for: updatedPageInfo, + optimisticMessages: (self?.optimisticallyInsertedMessages.wrappedValue.values) + .map { Array($0) }, + initialUnreadInteractionId: self?.initialUnreadInteractionId + ), currentDataRetriever: { self?.interactionData }, onDataChange: self?.onInteractionChange, onUnobservedDataChange: { updatedData, changeset in - self?.unobservedInteractionDataChanges = (updatedData, changeset) + self?.unobservedInteractionDataChanges = (changeset.isEmpty ? + nil : + (updatedData, changeset) + ) } ) } ) } - private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { + private func process( + data: [MessageViewModel], + for pageInfo: PagedData.PageInfo, + optimisticMessages: [MessageViewModel]?, + initialUnreadInteractionId: Int64? + ) -> [SectionModel] { let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true }) let sortedData: [MessageViewModel] = data - .filter { $0.isTypingIndicator != true } + .appending(contentsOf: (optimisticMessages ?? [])) + .filter { !$0.cellType.isPostProcessed } .sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs } // We load messages from newest to oldest so having a pageOffset larger than zero means @@ -338,20 +408,31 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { cellViewModel.id == sortedData .filter { $0.authorId == threadData.currentUserPublicKey || - $0.authorId == threadData.currentUserBlindedPublicKey + $0.authorId == threadData.currentUserBlinded15PublicKey || + $0.authorId == threadData.currentUserBlinded25PublicKey } .last? .id ), - currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey + currentUserBlinded15PublicKey: threadData.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: threadData.currentUserBlinded25PublicKey ) } .reduce([]) { result, message in + let updatedResult: [MessageViewModel] = result + .appending(initialUnreadInteractionId == nil || message.id != initialUnreadInteractionId ? + nil : + MessageViewModel( + timestampMs: message.timestampMs, + cellType: .unreadMarker + ) + ) + guard message.shouldShowDateHeader else { - return result.appending(message) + return updatedResult.appending(message) } - return result + return updatedResult .appending( MessageViewModel( timestampMs: message.timestampMs, @@ -374,12 +455,152 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { self.interactionData = updatedData } - public func expandReactions(for interactionId: Int64) { - reactionExpandedInteractionIds.insert(interactionId) + // MARK: - Optimistic Message Handling + + public typealias OptimisticMessageData = ( + id: UUID, + interaction: Interaction, + attachmentData: Attachment.PreparedData?, + linkPreviewAttachment: Attachment? + ) + + private var optimisticallyInsertedMessages: Atomic<[UUID: MessageViewModel]> = Atomic([:]) + private var optimisticMessageAssociatedInteractionIds: Atomic<[Int64: UUID]> = Atomic([:]) + + public func optimisticallyAppendOutgoingMessage( + text: String?, + sentTimestampMs: Int64, + attachments: [SignalAttachment]?, + linkPreviewDraft: LinkPreviewDraft?, + quoteModel: QuotedReplyModel? + ) -> OptimisticMessageData { + // Generate the optimistic data + let optimisticMessageId: UUID = UUID() + let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser() + let interaction: Interaction = Interaction( + threadId: threadData.threadId, + authorId: (threadData.currentUserBlinded15PublicKey ?? threadData.currentUserPublicKey), + variant: .standardOutgoing, + body: text, + timestampMs: sentTimestampMs, + hasMention: Interaction.isUserMentioned( + publicKeysToCheck: [ + threadData.currentUserPublicKey, + threadData.currentUserBlinded15PublicKey, + threadData.currentUserBlinded25PublicKey + ].compactMap { $0 }, + body: text + ), + expiresInSeconds: threadData.disappearingMessagesConfiguration + .map { disappearingConfig in + guard disappearingConfig.isEnabled else { return nil } + + return disappearingConfig.durationSeconds + }, + linkPreviewUrl: linkPreviewDraft?.urlString + ) + let optimisticAttachments: Attachment.PreparedData? = attachments + .map { Attachment.prepare(attachments: $0) } + let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in + try? LinkPreview.generateAttachmentIfPossible( + imageData: draft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + ) + } + let optimisticData: OptimisticMessageData = ( + optimisticMessageId, + interaction, + optimisticAttachments, + linkPreviewAttachment + ) + + // Generate the actual 'MessageViewModel' + let messageViewModel: MessageViewModel = MessageViewModel( + threadId: threadData.threadId, + threadVariant: threadData.threadVariant, + threadHasDisappearingMessagesEnabled: (threadData.disappearingMessagesConfiguration?.isEnabled ?? false), + threadOpenGroupServer: threadData.openGroupServer, + threadOpenGroupPublicKey: threadData.openGroupPublicKey, + threadContactNameInternal: threadData.threadContactName(), + timestampMs: interaction.timestampMs, + receivedAtTimestampMs: interaction.receivedAtTimestampMs, + authorId: interaction.authorId, + authorNameInternal: currentUserProfile.displayName(), + body: interaction.body, + expiresStartedAtMs: interaction.expiresStartedAtMs, + expiresInSeconds: interaction.expiresInSeconds, + isSenderOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin( + threadData.currentUserPublicKey, + for: threadData.openGroupRoomToken, + on: threadData.openGroupServer + ), + currentUserProfile: currentUserProfile, + quote: quoteModel.map { model in + // Don't care about this optimistic quote (the proper one will be generated in the database) + Quote( + interactionId: -1, // Can't save to db optimistically + authorId: model.authorId, + timestampMs: model.timestampMs, + body: model.body, + attachmentId: model.attachment?.id + ) + }, + quoteAttachment: quoteModel?.attachment, + linkPreview: linkPreviewDraft.map { draft in + LinkPreview( + url: draft.urlString, + title: draft.title, + attachmentId: nil // Can't save to db optimistically + ) + }, + linkPreviewAttachment: linkPreviewAttachment, + attachments: optimisticAttachments?.attachments + ) + + optimisticallyInsertedMessages.mutate { $0[optimisticMessageId] = messageViewModel } + + // If we can't get the current page data then don't bother trying to update (it's not going to work) + guard let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue else { + return optimisticData + } + + /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above + let currentData: [SectionModel] = (unobservedInteractionDataChanges?.0 ?? interactionData) + + PagedData.processAndTriggerUpdates( + updatedData: process( + data: (currentData.first(where: { $0.model == .messages })?.elements ?? []), + for: currentPageInfo, + optimisticMessages: Array(optimisticallyInsertedMessages.wrappedValue.values), + initialUnreadInteractionId: initialUnreadInteractionId + ), + currentDataRetriever: { [weak self] in self?.interactionData }, + onDataChange: self.onInteractionChange, + onUnobservedDataChange: { [weak self] updatedData, changeset in + self?.unobservedInteractionDataChanges = (changeset.isEmpty ? + nil : + (updatedData, changeset) + ) + } + ) + + return optimisticData } - public func collapseReactions(for interactionId: Int64) { - reactionExpandedInteractionIds.remove(interactionId) + /// Record an association between an `optimisticMessageId` and a specific `interactionId` + public func associate(optimisticMessageId: UUID, to interactionId: Int64?) { + guard let interactionId: Int64 = interactionId else { return } + + optimisticMessageAssociatedInteractionIds.mutate { $0[interactionId] = optimisticMessageId } + } + + /// Remove any optimisticUpdate entries which have an associated interactionId in the provided data + private func resolveOptimisticUpdates(with data: [MessageViewModel]) { + let interactionIds: [Int64] = data.map { $0.id } + let idsToRemove: [UUID] = optimisticMessageAssociatedInteractionIds + .mutate { associatedIds in interactionIds.compactMap { associatedIds.removeValue(forKey: $0) } } + + optimisticallyInsertedMessages.mutate { messages in idsToRemove.forEach { messages.removeValue(forKey: $0) } } } // MARK: - Mentions @@ -391,7 +612,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { .read { db -> [MentionInfo] in let userPublicKey: String = getUserHexEncodedPublicKey(db) let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self) - let capabilities: Set = (threadData.threadVariant != .openGroup ? + let capabilities: Set = (threadData.threadVariant != .community ? nil : try? Capability .select(.variant) @@ -400,9 +621,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { .fetchSet(db) ) .defaulting(to: []) - let targetPrefix: SessionId.Prefix = (capabilities.contains(.blind) ? - .blinded : - .standard + let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ? + [.blinded15, .blinded25] : + [.standard] ) return (try MentionInfo @@ -410,7 +631,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { userPublicKey: userPublicKey, threadId: threadData.threadId, threadVariant: threadData.threadVariant, - targetPrefix: targetPrefix, + targetPrefixes: targetPrefixes, pattern: pattern )? .fetchAll(db)) @@ -443,37 +664,53 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { } } - /// This method will mark all interactions as read before the specified interaction id, if no id is provided then all interactions for - /// the thread will be marked as read - public func markAsRead(beforeInclusive interactionId: Int64?) { + /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read + public func markAsRead( + target: SessionThreadViewModel.ReadTarget, + timestampMs: Int64? + ) { /// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database /// write queue when it isn't needed, in order to do this we: + /// - Throttle the updates to 100ms (quick enough that users shouldn't notice, but will help the DB when the user flings the list) + /// - Only mark interactions as read if they have newer `timestampMs` or `id` values (ie. were sent later or were more-recent + /// entries in the database), **Note:** Old messages will be marked as read upon insertion so shouldn't be an issue /// - /// - Don't bother marking anything as read if there are no unread interactions (we can rely on the - /// `threadData.threadUnreadCount` to always be accurate) - /// - Don't bother marking anything as read if this was called with the same `interactionId` that we - /// previously marked as read (ie. when scrolling and the last message hasn't changed) - guard - (self.threadData.threadUnreadCount ?? 0) > 0, - let targetInteractionId: Int64 = (interactionId ?? self.threadData.interactionId), - self.lastInteractionIdMarkedAsRead != targetInteractionId - else { return } - - let threadId: String = self.threadData.threadId - let threadVariant: SessionThread.Variant = self.threadData.threadVariant - let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false) - self.lastInteractionIdMarkedAsRead = targetInteractionId - - Storage.shared.writeAsync { db in - try Interaction.markAsRead( - db, - interactionId: targetInteractionId, - threadId: threadId, - threadVariant: threadVariant, - includingOlder: true, - trySendReadReceipt: trySendReadReceipt - ) + /// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read + if markAsReadPublisher == nil { + markAsReadPublisher = markAsReadTrigger + .throttle(for: .milliseconds(100), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true) + .handleEvents( + receiveOutput: { [weak self] target, timestampMs in + switch target { + case .thread: self?.threadData.markAsRead(target: target) + case .threadAndInteractions(let interactionId): + guard + timestampMs == nil || + (self?.lastInteractionTimestampMsMarkedAsRead ?? 0) < (timestampMs ?? 0) || + (self?.lastInteractionIdMarkedAsRead ?? 0) < (interactionId ?? 0) + else { + self?.threadData.markAsRead(target: .thread) + return + } + + // If we were given a timestamp then update the 'lastInteractionTimestampMsMarkedAsRead' + // to avoid needless updates + if let timestampMs: Int64 = timestampMs { + self?.lastInteractionTimestampMsMarkedAsRead = timestampMs + } + + self?.lastInteractionIdMarkedAsRead = (interactionId ?? self?.threadData.interactionId) + self?.threadData.markAsRead(target: target) + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + + markAsReadPublisher?.sinkUntilComplete() } + + markAsReadTrigger.send((target, timestampMs)) } public func swapToThread(updatedThreadId: String) { @@ -489,7 +726,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { self.pagedDataObserver = self.setupPagedObserver( for: updatedThreadId, userPublicKey: getUserHexEncodedPublicKey(), - blindedPublicKey: nil + blinded15PublicKey: nil, + blinded25PublicKey: nil ) // Try load everything up to the initial visible message, fallback to just the initial page of messages @@ -539,14 +777,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { Storage.shared.writeAsync { db in try Contact .filter(id: threadId) - .updateAll(db, Contact.Columns.isBlocked.set(to: false)) - - try MessageSender - .syncConfiguration(db, forceSyncNow: true) - .retainUntilComplete() + .updateAllAndConfig(db, Contact.Columns.isBlocked.set(to: false)) } } + public func expandReactions(for interactionId: Int64) { + reactionExpandedInteractionIds.insert(interactionId) + } + + public func collapseReactions(for interactionId: Int64) { + reactionExpandedInteractionIds.remove(interactionId) + } + // MARK: - Audio Playback public struct PlaybackInfo { @@ -648,6 +890,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // Then setup the state for the new audio currentPlayingInteraction.mutate { $0 = viewModel.id } + let currentPlaybackTime: TimeInterval? = playbackInfo.wrappedValue[viewModel.id]?.progress 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 @@ -660,7 +903,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { delegate: self ) audioPlayer.play() - audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0) + audioPlayer.setCurrentTime(currentPlaybackTime ?? 0) player = audioPlayer } } diff --git a/Session/Conversations/Emoji Picker/EmojiPickerCollectionView.swift b/Session/Conversations/Emoji Picker/EmojiPickerCollectionView.swift index a8dceb7da..0f251c925 100644 --- a/Session/Conversations/Emoji Picker/EmojiPickerCollectionView.swift +++ b/Session/Conversations/Emoji Picker/EmojiPickerCollectionView.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SessionUtilitiesKit +import SignalCoreKit protocol EmojiPickerCollectionViewDelegate: AnyObject { func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones) diff --git a/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift b/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift index ecf7ae71b..89cdebba5 100644 --- a/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift +++ b/Session/Conversations/Emoji Picker/EmojiPickerSheet.swift @@ -177,9 +177,6 @@ class EmojiPickerSheet: BaseVC { 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, diff --git a/Session/Conversations/Emoji Picker/EmojiSkinTonePicker.swift b/Session/Conversations/Emoji Picker/EmojiSkinTonePicker.swift index 4889dffa9..5c6da4152 100644 --- a/Session/Conversations/Emoji Picker/EmojiSkinTonePicker.swift +++ b/Session/Conversations/Emoji Picker/EmojiSkinTonePicker.swift @@ -2,6 +2,8 @@ import UIKit import SessionUIKit +import SignalCoreKit +import SignalUtilitiesKit class EmojiSkinTonePicker: UIView { let emoji: Emoji diff --git a/Session/Conversations/Input View/InputTextView.swift b/Session/Conversations/Input View/InputTextView.swift index aaaf0e3f0..cba081e8e 100644 --- a/Session/Conversations/Input View/InputTextView.swift +++ b/Session/Conversations/Input View/InputTextView.swift @@ -50,7 +50,7 @@ public final class InputTextView: UITextView, UITextViewDelegate { public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(paste(_:)) { - if let _ = UIPasteboard.general.image { + if UIPasteboard.general.hasImages { return true } } diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 4727b53fa..60e67ba4f 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -1,15 +1,18 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit +import SignalUtilitiesKit final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate { // MARK: - Variables private static let linkPreviewViewInset: CGFloat = 6 + private var disposables: Set = Set() private let threadVariant: SessionThread.Variant private weak var delegate: InputViewDelegate? @@ -89,7 +92,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M let result: UIView = UIView() result.accessibilityLabel = "Mentions list" result.accessibilityIdentifier = "Mentions list" - result.isAccessibilityElement = true result.alpha = 0 let backgroundView = UIView() @@ -263,7 +265,8 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M quotedText: quoteDraftInfo.model.body, threadVariant: threadVariant, currentUserPublicKey: quoteDraftInfo.model.currentUserPublicKey, - currentUserBlindedPublicKey: quoteDraftInfo.model.currentUserBlindedPublicKey, + currentUserBlinded15PublicKey: quoteDraftInfo.model.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: quoteDraftInfo.model.currentUserBlinded25PublicKey, direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming), attachment: quoteDraftInfo.model.attachment, hInset: hInset, @@ -330,19 +333,27 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // 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() + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure: + guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete + + self?.linkPreviewInfo = nil + self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + } + }, + receiveValue: { [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) + } + ) + .store(in: &disposables) } func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) { @@ -491,7 +502,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M func showMentionsUI(for candidates: [MentionInfo]) { mentionsView.candidates = candidates - let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing) + let mentionCellHeight = (ProfilePictureView.Size.message.viewSize + 2 * Values.smallSpacing) mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight layoutIfNeeded() diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 881059cc3..7945e40c0 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -92,6 +92,11 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele ), isLast: (indexPath.row == (candidates.count - 1)) ) + cell.accessibilityIdentifier = "Contact" + cell.accessibilityLabel = candidates[indexPath.row].profile.displayName( + for: candidates[indexPath.row].threadVariant + ) + cell.isAccessibilityElement = true return cell } @@ -111,9 +116,7 @@ private extension MentionSelectionView { final class Cell: UITableViewCell { // MARK: - UI - private lazy var profilePictureView: ProfilePictureView = ProfilePictureView() - - private lazy var moderatorIconImageView: UIImageView = UIImageView(image: #imageLiteral(resourceName: "Crown")) + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .message) private lazy var displayNameLabel: UILabel = { let result: UILabel = UILabel() @@ -155,18 +158,12 @@ private extension MentionSelectionView { selectedBackgroundView.themeBackgroundColor = .highlighted(.settings_tabBackground) 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 mainStackView.alignment = .center mainStackView.spacing = Values.mediumSpacing - mainStackView.set(.height, to: profilePictureViewSize) + mainStackView.set(.height, to: ProfilePictureView.Size.message.viewSize) contentView.addSubview(mainStackView) mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing) mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing) @@ -174,13 +171,6 @@ private extension MentionSelectionView { 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) @@ -199,10 +189,11 @@ private extension MentionSelectionView { displayNameLabel.text = profile.displayName(for: threadVariant) profilePictureView.update( publicKey: profile.id, + threadVariant: .contact, // Always show the display picture in 'contact' mode + customImageData: nil, profile: profile, - threadVariant: threadVariant + profileIcon: (isUserModeratorOrAdmin ? .crown : .none) ) - moderatorIconImageView.isHidden = !isUserModeratorOrAdmin separator.isHidden = isLast } } diff --git a/Session/Conversations/Message Cells/Content Views/DocumentView.swift b/Session/Conversations/Message Cells/Content Views/DocumentView.swift index 88d2dc07c..39115cbec 100644 --- a/Session/Conversations/Message Cells/Content Views/DocumentView.swift +++ b/Session/Conversations/Message Cells/Content Views/DocumentView.swift @@ -46,7 +46,7 @@ final class DocumentView: UIView { // Size label let sizeLabel = UILabel() sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize) - sizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount) + sizeLabel.text = Format.fileSize(attachment.byteCount) sizeLabel.themeTextColor = textColor sizeLabel.lineBreakMode = .byTruncatingTail diff --git a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift index 054d19271..20fbbe748 100644 --- a/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift +++ b/Session/Conversations/Message Cells/Content Views/LinkPreviewState.swift @@ -2,6 +2,7 @@ import UIKit import SessionMessagingKit +import SignalCoreKit protocol LinkPreviewState { var isLoaded: Bool { get } diff --git a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift index a4cdf7e67..87ee2f937 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift @@ -2,6 +2,7 @@ import UIKit import SessionMessagingKit +import SignalCoreKit public class MediaAlbumView: UIStackView { private let items: [Attachment] @@ -110,11 +111,10 @@ public class MediaAlbumView: UIStackView { 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 + "\(moreCount)" ) let moreLabel: UILabel = UILabel() moreLabel.font = .systemFont(ofSize: 24) diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 507b72917..0af198314 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -4,6 +4,8 @@ import UIKit import YYImage import SessionUIKit import SessionMessagingKit +import SignalCoreKit +import SignalUtilitiesKit public class MediaView: UIView { static let contentMode: UIView.ContentMode = .scaleAspectFill diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 69e90a357..bda79ae51 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -30,7 +30,8 @@ final class QuoteView: UIView { quotedText: String?, threadVariant: SessionThread.Variant, currentUserPublicKey: String?, - currentUserBlindedPublicKey: String?, + currentUserBlinded15PublicKey: String?, + currentUserBlinded25PublicKey: String?, direction: Direction, attachment: Attachment?, hInset: CGFloat, @@ -47,7 +48,8 @@ final class QuoteView: UIView { quotedText: quotedText, threadVariant: threadVariant, currentUserPublicKey: currentUserPublicKey, - currentUserBlindedPublicKey: currentUserBlindedPublicKey, + currentUserBlinded15PublicKey: currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: currentUserBlinded25PublicKey, direction: direction, attachment: attachment, hInset: hInset, @@ -69,7 +71,8 @@ final class QuoteView: UIView { quotedText: String?, threadVariant: SessionThread.Variant, currentUserPublicKey: String?, - currentUserBlindedPublicKey: String?, + currentUserBlinded15PublicKey: String?, + currentUserBlinded25PublicKey: String?, direction: Direction, attachment: Attachment?, hInset: CGFloat, @@ -119,17 +122,6 @@ final class QuoteView: UIView { 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: ThemeValue = { - switch mode { - case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary) - case .draft: return .primary - } - }() - let lineView = UIView() - lineView.themeBackgroundColor = lineColor - lineView.set(.width, to: Values.accentLineThickness) - if let attachment: Attachment = attachment { let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType) let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black") @@ -181,13 +173,26 @@ final class QuoteView: UIView { } } else { + // Line view + let lineColor: ThemeValue = { + switch mode { + case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary) + case .draft: return .primary + } + }() + let lineView = UIView() + lineView.themeBackgroundColor = lineColor mainStackView.addArrangedSubview(lineView) + + lineView.pin(.top, to: .top, of: mainStackView) + lineView.pin(.bottom, to: .bottom, of: mainStackView) + lineView.set(.width, to: Values.accentLineThickness) } // Body label let bodyLabel = TappableLabel() - bodyLabel.numberOfLines = 0 bodyLabel.lineBreakMode = .byTruncatingTail + bodyLabel.numberOfLines = 2 let targetThemeColor: ThemeValue = { switch mode { @@ -209,7 +214,8 @@ final class QuoteView: UIView { in: $0, threadVariant: threadVariant, currentUserPublicKey: currentUserPublicKey, - currentUserBlindedPublicKey: currentUserBlindedPublicKey, + currentUserBlinded15PublicKey: currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: currentUserBlinded25PublicKey, isOutgoingMessage: (direction == .outgoing), textColor: textColor, theme: theme, @@ -229,11 +235,11 @@ final class QuoteView: UIView { // Label stack view let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace) - var authorLabelHeight: CGFloat? let isCurrentUser: Bool = [ currentUserPublicKey, - currentUserBlindedPublicKey, + currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey ] .compactMap { $0 } .asSet() @@ -259,16 +265,12 @@ final class QuoteView: UIView { authorLabel.themeTextColor = targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail authorLabel.isHidden = (authorLabel.text == nil) - - let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) - authorLabel.set(.height, to: authorLabelSize.height) - authorLabelHeight = authorLabelSize.height + authorLabel.numberOfLines = 1 let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ]) labelStackView.axis = .vertical labelStackView.spacing = labelStackViewSpacing labelStackView.distribution = .equalCentering - labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width)) labelStackView.isLayoutMarginsRelativeArrangement = true labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0) mainStackView.addArrangedSubview(labelStackView) @@ -277,29 +279,6 @@ final class QuoteView: UIView { contentView.addSubview(mainStackView) mainStackView.pin(to: contentView) - if threadVariant != .openGroup && threadVariant != .closedGroup { - bodyLabel.set(.width, to: bodyLabelSize.width) - } - - let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40)) - let contentViewHeight: CGFloat - - 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 { - if let authorLabelHeight = authorLabelHeight { // Group thread - contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin - } - 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 mode == .draft { // Cancel button let cancelButton = UIButton(type: .custom) diff --git a/Session/Conversations/Message Cells/Content Views/ReactionContainerView.swift b/Session/Conversations/Message Cells/Content Views/ReactionContainerView.swift index 94f52b6d5..1f2cd628f 100644 --- a/Session/Conversations/Message Cells/Content Views/ReactionContainerView.swift +++ b/Session/Conversations/Message Cells/Content Views/ReactionContainerView.swift @@ -9,6 +9,13 @@ final class ReactionContainerView: UIView { private static let arrowSize: CGSize = CGSize(width: 15, height: 13) private static let arrowSpacing: CGFloat = Values.verySmallSpacing + // We have explicit limits on the number of emoji which should be displayed before they + // automatically get collapsed, these values are consistent across platforms so are set + // here (even though the logic will automatically calculate and limit to a single line + // of reactions dynamically for the size of the view) + private static let numCollapsedEmoji: Int = 4 + private static let maxEmojiBeforeCollapse: Int = 6 + private var maxWidth: CGFloat = 0 private var collapsedCount: Int = 0 private var showingAllReactions: Bool = false @@ -173,7 +180,10 @@ final class ReactionContainerView: UIView { numReactions += 1 } - return numReactions + return (numReactions > ReactionContainerView.maxEmojiBeforeCollapse ? + ReactionContainerView.numCollapsedEmoji : + numReactions + ) }() self.showNumbers = showNumbers self.reactionViews = [] diff --git a/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift b/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift index 860388558..9af77393f 100644 --- a/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift +++ b/Session/Conversations/Message Cells/Content Views/TypingIndicatorView.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SignalCoreKit @objc class TypingIndicatorView: UIStackView { // This represents the spacing between the dots diff --git a/Session/Conversations/Message Cells/DateHeaderCell.swift b/Session/Conversations/Message Cells/DateHeaderCell.swift index f6a99ca3f..0f37ed4e4 100644 --- a/Session/Conversations/Message Cells/DateHeaderCell.swift +++ b/Session/Conversations/Message Cells/DateHeaderCell.swift @@ -4,6 +4,7 @@ import UIKit import SignalUtilitiesKit import SessionUtilitiesKit import SessionMessagingKit +import SessionUIKit final class DateHeaderCell: MessageCell { // MARK: - UI diff --git a/Session/Conversations/Message Cells/InfoMessageCell.swift b/Session/Conversations/Message Cells/InfoMessageCell.swift index c1f7e63a1..103ab54ea 100644 --- a/Session/Conversations/Message Cells/InfoMessageCell.swift +++ b/Session/Conversations/Message Cells/InfoMessageCell.swift @@ -6,7 +6,7 @@ import SessionMessagingKit final class InfoMessageCell: MessageCell { private static let iconSize: CGFloat = 16 - private static let inset = Values.mediumSpacing + public static let inset = Values.mediumSpacing private var isHandlingLongPress: Bool = false diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 44886ba9c..dde344352 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -65,6 +65,7 @@ public class MessageCell: UITableViewCell { static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type { guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self } guard viewModel.cellType != .dateHeader else { return DateHeaderCell.self } + guard viewModel.cellType != .unreadMarker else { return UnreadMarkerCell.self } switch viewModel.variant { case .standardOutgoing, .standardIncoming, .standardIncomingDeleted: diff --git a/Session/Conversations/Message Cells/UnreadMarkerCell.swift b/Session/Conversations/Message Cells/UnreadMarkerCell.swift new file mode 100644 index 000000000..2ab5f89cf --- /dev/null +++ b/Session/Conversations/Message Cells/UnreadMarkerCell.swift @@ -0,0 +1,74 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SignalUtilitiesKit +import SessionUtilitiesKit +import SessionMessagingKit +import SessionUIKit + +final class UnreadMarkerCell: MessageCell { + public static let height: CGFloat = 32 + + // MARK: - UI + + private let leftLine: UIView = { + let result: UIView = UIView() + result.themeBackgroundColor = .unreadMarker + result.set(.height, to: 1) // Intentionally 1 instead of 'separatorThickness' + + return result + }() + + private lazy var titleLabel: UILabel = { + let result = UILabel() + result.font = .boldSystemFont(ofSize: Values.smallFontSize) + result.text = "UNREAD_MESSAGES".localized() + result.themeTextColor = .unreadMarker + result.textAlignment = .center + + return result + }() + + private let rightLine: UIView = { + let result: UIView = UIView() + result.themeBackgroundColor = .unreadMarker + result.set(.height, to: 1) // Intentionally 1 instead of 'separatorThickness' + + return result + }() + + // MARK: - Initialization + + override func setUpViewHierarchy() { + super.setUpViewHierarchy() + + addSubview(leftLine) + addSubview(titleLabel) + addSubview(rightLine) + + leftLine.pin(.leading, to: .leading, of: self, withInset: Values.mediumSpacing) + leftLine.pin(.trailing, to: .leading, of: titleLabel, withInset: -Values.smallSpacing) + leftLine.center(.vertical, in: self) + titleLabel.center(.horizontal, in: self) + titleLabel.center(.vertical, in: self) + titleLabel.pin(.top, to: .top, of: self, withInset: Values.smallSpacing) + titleLabel.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing) + rightLine.pin(.leading, to: .trailing, of: titleLabel, withInset: Values.smallSpacing) + rightLine.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing) + rightLine.center(.vertical, in: self) + } + + // MARK: - Updating + + override func update( + with cellViewModel: MessageViewModel, + mediaCache: NSCache, + playbackInfo: ConversationViewModel.PlaybackInfo?, + showExpandedReactions: Bool, + lastSearchText: String? + ) { + guard cellViewModel.cellType == .unreadMarker else { return } + } + + override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {} +} diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index fc1c853a3..6019d5a4d 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -22,7 +22,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self) private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0) private lazy var profilePictureViewLeadingConstraint = profilePictureView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.groupThreadHSpacing) - private lazy var profilePictureViewWidthConstraint = profilePictureView.set(.width, to: Values.verySmallProfilePictureSize) private lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing) private lazy var contentViewLeadingConstraint2 = snContentView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: VisibleMessageCell.gutterSize) private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing) @@ -51,22 +50,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { private lazy var viewsToMoveForReply: [UIView] = [ snContentView, profilePictureView, - moderatorIconImageView, replyButton, timerView, messageStatusImageView, reactionContainerView ] - private lazy var profilePictureView: ProfilePictureView = { - 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")) + private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .message) lazy var bubbleBackgroundView: UIView = { let result = UIView() @@ -176,7 +166,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { private static let messageStatusImageViewSize: CGFloat = 12 private static let authorLabelBottomSpacing: CGFloat = 4 private static let groupThreadHSpacing: CGFloat = 12 - private static let profilePictureSize = Values.verySmallProfilePictureSize private static let authorLabelInset: CGFloat = 12 private static let replyButtonSize: CGFloat = 24 private static let maxBubbleTranslationX: CGFloat = 40 @@ -186,7 +175,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { static let contactThreadHSpacing = Values.mediumSpacing static var gutterSize: CGFloat = { - var result = groupThreadHSpacing + profilePictureSize + groupThreadHSpacing + var result = groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing if UIDevice.current.isIPad { result += 168 @@ -195,7 +184,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return result }() - static var leftGutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing } + static var leftGutterSize: CGFloat { groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing } // MARK: Direction & Position @@ -214,21 +203,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { // Profile picture view addSubview(profilePictureView) profilePictureViewLeadingConstraint.isActive = true - profilePictureViewWidthConstraint.isActive = true - - // 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) // Content view addSubview(snContentView) contentViewLeadingConstraint1.isActive = true contentViewTopConstraint.isActive = true contentViewTrailingConstraint1.isActive = true - snContentView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: -1) + snContentView.pin(.bottom, to: .bottom, of: profilePictureView) // Bubble background view bubbleBackgroundView.addSubview(bubbleView) @@ -301,9 +282,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { lastSearchText: String? ) { self.viewModel = cellViewModel - self.bubbleView.accessibilityIdentifier = "Message Body" - self.bubbleView.isAccessibilityElement = true - self.bubbleView.accessibilityLabel = cellViewModel.body + // We want to add spacing between "clusters" of messages to indicate that time has // passed (even if there wasn't enough time to warrant showing a date header) let shouldAddTopInset: Bool = ( @@ -313,18 +292,28 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { cellViewModel.isOnlyMessageInCluster ) ) - let isGroupThread: Bool = (cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .closedGroup) + let isGroupThread: Bool = ( + cellViewModel.threadVariant == .community || + cellViewModel.threadVariant == .legacyGroup || + cellViewModel.threadVariant == .group + ) - // Profile picture view + // Profile picture view (should always be handled as a standard 'contact' profile picture) + let profileShouldBeVisible: Bool = ( + cellViewModel.canHaveProfile && + cellViewModel.shouldShowProfile && + cellViewModel.profile != nil + ) profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0) - profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0) - profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil) + profilePictureView.isHidden = !cellViewModel.canHaveProfile + profilePictureView.alpha = (profileShouldBeVisible ? 1 : 0) profilePictureView.update( publicKey: cellViewModel.authorId, + threadVariant: .contact, // Always show the display picture in 'contact' mode + customImageData: nil, profile: cellViewModel.profile, - threadVariant: cellViewModel.threadVariant + profileIcon: (cellViewModel.isSenderOpenGroupModerator ? .crown : .none) ) - moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile) // Bubble view contentViewLeadingConstraint1.isActive = ( @@ -356,6 +345,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { lastSearchText: lastSearchText ) + bubbleView.accessibilityIdentifier = "Message Body" + bubbleView.accessibilityLabel = bodyTappableLabel?.attributedText?.string + bubbleView.isAccessibilityElement = true + // Author label authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0) authorLabel.isHidden = (cellViewModel.senderName == nil) @@ -392,11 +385,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { ) // Swipe to reply - if cellViewModel.variant == .standardIncomingDeleted || cellViewModel.variant == .infoCall { - removeGestureRecognizer(panGestureRecognizer) + if ContextMenuVC.viewModelCanReply(cellViewModel) { + addGestureRecognizer(panGestureRecognizer) } else { - addGestureRecognizer(panGestureRecognizer) + removeGestureRecognizer(panGestureRecognizer) } // Under bubble content @@ -496,7 +489,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { } switch cellViewModel.cellType { - case .typingIndicator, .dateHeader: break + case .typingIndicator, .dateHeader, .unreadMarker: break case .textOnlyMessage: let inset: CGFloat = 12 @@ -549,7 +542,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { quotedText: quote.body, threadVariant: cellViewModel.threadVariant, currentUserPublicKey: cellViewModel.currentUserPublicKey, - currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, + currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey, direction: (cellViewModel.variant == .standardOutgoing ? .outgoing : .incoming @@ -718,8 +712,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { maxWidth: maxWidth, showingAllReactions: showExpandedReactions, showNumbers: ( - cellViewModel.threadVariant == .closedGroup || - cellViewModel.threadVariant == .openGroup + cellViewModel.threadVariant == .legacyGroup || + cellViewModel.threadVariant == .group || + cellViewModel.threadVariant == .community ) ) } @@ -873,8 +868,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), 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 } + guard cellViewModel.threadVariant != .community else { + // FIXME: Add in support for opening a conversation with a 'blinded25' id + guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded15 else { return } delegate?.startThread( with: cellViewModel.authorId, @@ -1083,8 +1079,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { case .standardIncoming, .standardIncomingDeleted: let isGroupThread = ( - cellViewModel.threadVariant == .openGroup || - cellViewModel.threadVariant == .closedGroup + cellViewModel.threadVariant == .community || + cellViewModel.threadVariant == .legacyGroup || + cellViewModel.threadVariant == .group ) let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing) @@ -1123,7 +1120,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { in: (cellViewModel.body ?? ""), threadVariant: cellViewModel.threadVariant, currentUserPublicKey: cellViewModel.currentUserPublicKey, - currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey, + currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey, isOutgoingMessage: isOutgoing, textColor: actualTextColor, theme: theme, diff --git a/Session/Conversations/Settings/OWSMessageTimerView.m b/Session/Conversations/Settings/OWSMessageTimerView.m index ad2a924cf..268c3280a 100644 --- a/Session/Conversations/Settings/OWSMessageTimerView.m +++ b/Session/Conversations/Settings/OWSMessageTimerView.m @@ -6,6 +6,7 @@ #import "OWSMath.h" #import "UIView+OWS.h" #import +#import #import #import #import diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift similarity index 80% rename from Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift rename to Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift index a562ee556..86a5ac164 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesSettingsViewModel.swift @@ -7,8 +7,9 @@ import DifferenceKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit +import SessionSnodeKit -class ThreadDisappearingMessagesViewModel: SessionTableViewModel { +class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel { // MARK: - Config enum NavButton: Equatable { @@ -30,6 +31,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel @@ -39,10 +41,12 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel [SectionModel] in let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel @@ -115,7 +116,10 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel = { - isEditing - .map { isEditing in (isEditing ? .editing : .standard) } + Publishers + .CombineLatest( + isEditing, + textChanged + .handleEvents( + receiveOutput: { [weak self] value, _ in + self?.editedDisplayName = value + } + ) + .filter { _ in false } + .prepend((nil, .nickname)) + ) + .map { isEditing, _ -> NavState in (isEditing ? .editing : .standard) } .removeDuplicates() .prepend(.standard) // Initial value + .shareReplay(1) .eraseToAnyPublisher() }() @@ -138,7 +153,7 @@ class ThreadSettingsViewModel: SessionTableViewModel [SectionModel] in let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel .conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey) .fetchOne(db) - guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { return [] } + // If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted + // so dismiss the screen + guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { + self?.dismissScreen(type: .popToRoot) + return [] + } // Additional Queries let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] @@ -204,68 +221,148 @@ class ThreadSettingsViewModel: SessionTableViewModel (mutedUntilTimestamp ?? 0) else { subtitleLabel?.attributedText = NSAttributedString( - string: "\u{e067} ", + string: FullConversationCell.mutePrefix, attributes: [ - .font: UIFont.ows_elegantIconsFont(10), + .font: UIFont(name: "ElegantIcons", size: 10) as Any, .foregroundColor: textPrimary ] ) @@ -168,12 +171,12 @@ final class ConversationTitleView: UIView { switch threadVariant { case .contact: break - case .closedGroup: + case .legacyGroup, .group: subtitleLabel?.attributedText = NSAttributedString( string: "\(userCount) member\(userCount == 1 ? "" : "s")" ) - case .openGroup: + case .community: subtitleLabel?.attributedText = NSAttributedString( string: "\(userCount) active member\(userCount == 1 ? "" : "s")" ) diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index d6b7cb4a2..a425ff54b 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -431,7 +431,8 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { cell.update( with: SessionCell.Info( id: cellViewModel, - leftAccessory: .profile(authorId, cellViewModel.profile), + position: Position.with(indexPath.row, count: self.selectedReactionUserList.count), + leftAccessory: .profile(id: authorId, profile: cellViewModel.profile), title: ( cellViewModel.profile?.displayName() ?? Profile.truncated( @@ -446,10 +447,9 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { size: .fit ) ), + styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), isEnabled: (authorId == self.messageViewModel.currentUserPublicKey) - ), - style: .edgeToEdge, - position: Position.with(indexPath.row, count: self.selectedReactionUserList.count) + ) ) return cell diff --git a/Session/Conversations/Views & Modals/ScrollToBottomButton.swift b/Session/Conversations/Views & Modals/RoundIconButton.swift similarity index 67% rename from Session/Conversations/Views & Modals/ScrollToBottomButton.swift rename to Session/Conversations/Views & Modals/RoundIconButton.swift index 413dbdbb2..74e6a7978 100644 --- a/Session/Conversations/Views & Modals/ScrollToBottomButton.swift +++ b/Session/Conversations/Views & Modals/RoundIconButton.swift @@ -3,8 +3,8 @@ import UIKit import SessionUIKit -final class ScrollToBottomButton: UIView { - private weak var delegate: ScrollToBottomButtonDelegate? +final class RoundIconButton: UIView { + private let onTap: () -> () // MARK: - Settings @@ -13,12 +13,12 @@ final class ScrollToBottomButton: UIView { // MARK: - Lifecycle - init(delegate: ScrollToBottomButtonDelegate) { - self.delegate = delegate + init(image: UIImage?, onTap: @escaping () -> ()) { + self.onTap = onTap super.init(frame: CGRect.zero) - setUpViewHierarchy() + setUpViewHierarchy(image: image) } override init(frame: CGRect) { @@ -29,7 +29,7 @@ final class ScrollToBottomButton: UIView { preconditionFailure("Use init(delegate:) instead.") } - private func setUpViewHierarchy() { + private func setUpViewHierarchy(image: UIImage?) { // Background & blur let backgroundView = UIView() backgroundView.themeBackgroundColor = .backgroundSecondary @@ -49,9 +49,9 @@ final class ScrollToBottomButton: UIView { } // Size & shape - set(.width, to: ScrollToBottomButton.size) - set(.height, to: ScrollToBottomButton.size) - layer.cornerRadius = (ScrollToBottomButton.size / 2) + set(.width, to: RoundIconButton.size) + set(.height, to: RoundIconButton.size) + layer.cornerRadius = (RoundIconButton.size / 2) layer.masksToBounds = true // Border @@ -59,16 +59,13 @@ final class ScrollToBottomButton: UIView { layer.borderWidth = Values.separatorThickness // Icon - let iconImageView = UIImageView( - image: UIImage(named: "ic_chevron_down")? - .withRenderingMode(.alwaysTemplate) - ) + let iconImageView = UIImageView(image: image) iconImageView.themeTintColor = .textPrimary iconImageView.contentMode = .scaleAspectFit addSubview(iconImageView) iconImageView.center(in: self) - iconImageView.set(.width, to: ScrollToBottomButton.iconSize) - iconImageView.set(.height, to: ScrollToBottomButton.iconSize) + iconImageView.set(.width, to: RoundIconButton.iconSize) + iconImageView.set(.height, to: RoundIconButton.iconSize) // Gesture recognizer let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) @@ -78,12 +75,6 @@ final class ScrollToBottomButton: UIView { // MARK: - Interaction @objc private func handleTap() { - delegate?.handleScrollToBottomButtonTapped() + onTap() } } - -// MARK: - ScrollToBottomButtonDelegate - -protocol ScrollToBottomButtonDelegate: AnyObject { - func handleScrollToBottomButtonTapped() -} diff --git a/Session/Emoji/Emoji+Available.swift b/Session/Emoji/Emoji+Available.swift index 5b17fa05e..3619859c1 100644 --- a/Session/Emoji/Emoji+Available.swift +++ b/Session/Emoji/Emoji+Available.swift @@ -1,4 +1,5 @@ import Foundation +import SignalCoreKit extension Emoji { private static let availableCache: Atomic<[Emoji:Bool]> = Atomic([:]) diff --git a/Session/Home/GlobalSearch/EmptySearchResultCell.swift b/Session/Home/GlobalSearch/EmptySearchResultCell.swift index 2c11764ae..5c82210bb 100644 --- a/Session/Home/GlobalSearch/EmptySearchResultCell.swift +++ b/Session/Home/GlobalSearch/EmptySearchResultCell.swift @@ -5,6 +5,7 @@ import PureLayout import SessionUIKit import SessionUtilitiesKit import NVActivityIndicatorView +import SignalCoreKit class EmptySearchResultCell: UITableViewCell { private lazy var messageLabel: UILabel = { diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index d6e07e269..7d4103a85 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -7,8 +7,9 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +import SignalCoreKit -class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { +class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource { fileprivate typealias SectionModel = ArraySection // MARK: - SearchSection @@ -19,6 +20,15 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo case messages } + // MARK: - SessionUtilRespondingViewController + + let isConversationList: Bool = true + + func forceRefreshIfNeeded() { + // Need to do this as the 'GlobalSearchViewController' doesn't observe database changes + updateSearchResults(searchText: searchText, force: true) + } + // MARK: - Variables private lazy var defaultSearchResults: [SectionModel] = { @@ -150,7 +160,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo } } - private func updateSearchResults(searchText rawSearchText: String) { + private func updateSearchResults(searchText rawSearchText: String, force: Bool = false) { let searchText = rawSearchText.stripped guard searchText.count > 0 else { @@ -161,7 +171,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo tableView.reloadData() return } - guard lastSearchText != searchText else { return } + guard force || lastSearchText != searchText else { return } lastSearchText = searchText @@ -207,7 +217,14 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo self?.termForCurrentSearchResultSet = searchText self?.searchResultSet = [ - (hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]), + (hasResults ? nil : [ + ArraySection( + model: .noResults, + elements: [ + SessionThreadViewModel(threadId: SessionThreadViewModel.invalidId) + ] + ) + ]), (hasResults ? sections : nil) ] .compactMap { $0 } @@ -271,23 +288,46 @@ extension GlobalSearchViewController { show( threadId: section.elements[indexPath.row].threadId, threadVariant: section.elements[indexPath.row].threadVariant, - focusedInteractionId: section.elements[indexPath.row].interactionId + focusedInteractionInfo: { + guard + let interactionId: Int64 = section.elements[indexPath.row].interactionId, + let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs + else { return nil } + + return Interaction.TimestampInfo( + id: interactionId, + timestampMs: timestampMs + ) + }() ) } } - private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil, animated: Bool = true) { + private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true) { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in - self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId, animated: animated) + self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, animated: animated) } return } + // If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the + // contact has been hidden) + if threadVariant == .contact { + Storage.shared.write { db in + try SessionThread.fetchOrCreate( + db, + id: threadId, + variant: threadVariant, + shouldBeVisible: nil // Don't change current state + ) + } + } + let viewController: ConversationVC = ConversationVC( threadId: threadId, threadVariant: threadVariant, - focusedInteractionId: focusedInteractionId + focusedInteractionInfo: focusedInteractionInfo ) self.navigationController?.pushViewController(viewController, animated: true) } diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 3475539da..c8d774f2e 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -8,18 +8,24 @@ import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit -final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate { +final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate { private static let loadingHeaderHeight: CGFloat = 40 public static let newConversationButtonSize: CGFloat = 60 private let viewModel: HomeViewModel = HomeViewModel() - private var dataChangeObservable: DatabaseCancellable? + private var dataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } 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: - SessionUtilRespondingViewController + + let isConversationList: Bool = true + // MARK: - Intialization init() { @@ -222,7 +228,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi // Preparation SessionApp.homeViewController.mutate { $0 = self } - updateNavBarButtons() + updateNavBarButtons(userProfile: self.viewModel.state.userProfile) setUpNavBarSessionHeading() // Recovery phrase reminder @@ -278,9 +284,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi 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() + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + if !SessionUtil.userConfigsEnabled { + // 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() + } } } @@ -320,26 +329,31 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi // MARK: - Updating - private func startObservingChanges(didReturnFromBackground: Bool = false) { - // Start observing for data changes + public func startObservingChanges(didReturnFromBackground: Bool = false, onReceivedInitialChange: (() -> ())? = nil) { + guard dataChangeObservable == nil else { return } + + var runAndClearInitialChangeCallback: (() -> ())? = nil + + runAndClearInitialChangeCallback = { [weak self] in + guard self?.hasLoadedInitialStateData == true && self?.hasLoadedInitialThreadData == true else { return } + + onReceivedInitialChange?() + runAndClearInitialChangeCallback = nil + } + 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) + runAndClearInitialChangeCallback?() } ) self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in self?.handleThreadUpdates(updatedThreadData, changeset: changeset) + runAndClearInitialChangeCallback?() } // Note: When returning from the background we could have received notifications but the @@ -354,7 +368,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi private func stopObservingChanges() { // Stop observing database changes - dataChangeObservable?.cancel() + self.dataChangeObservable = nil self.viewModel.onThreadChange = nil } @@ -368,7 +382,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi } if updatedState.userProfile != self.viewModel.state.userProfile { - updateNavBarButtons() + updateNavBarButtons(userProfile: updatedState.userProfile) } // Update the 'view seed' UI @@ -395,8 +409,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi // 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 { [weak self] in // Hide the 'loading conversations' label (now that we have received conversation data) self?.loadingConversationsLabel.isHidden = true @@ -408,6 +420,8 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi ) self?.viewModel.updateThreadData(updatedData) + self?.tableView.reloadData() + self?.hasLoadedInitialThreadData = true } return } @@ -446,7 +460,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi } private func autoLoadNextPageIfNeeded() { - guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } + guard + self.hasLoadedInitialThreadData && + !self.isAutoLoadingNextPage && + !self.isLoadingMore + else { return } self.isAutoLoadingNextPage = true @@ -475,21 +493,19 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi } } - private func updateNavBarButtons() { + private func updateNavBarButtons(userProfile: Profile) { // Profile picture view - let profilePictureSize = Values.verySmallProfilePictureSize - let profilePictureView = ProfilePictureView() + let profilePictureView = ProfilePictureView(size: .navigation) profilePictureView.accessibilityIdentifier = "User settings" profilePictureView.accessibilityLabel = "User settings" profilePictureView.isAccessibilityElement = true - profilePictureView.size = profilePictureSize profilePictureView.update( - publicKey: getUserHexEncodedPublicKey(), - profile: Profile.fetchOrCreateCurrentUser(), - threadVariant: .contact + publicKey: userProfile.id, + threadVariant: .contact, + customImageData: nil, + profile: userProfile, + additionalProfile: nil ) - profilePictureView.set(.width, to: profilePictureSize) - profilePictureView.set(.height, to: profilePictureSize) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings)) profilePictureView.addGestureRecognizer(tapGestureRecognizer) @@ -619,7 +635,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi variant: threadViewModel.threadVariant, isMessageRequest: (threadViewModel.threadIsMessageRequest == true), with: .none, - focusedInteractionId: nil, + focusedInteractionInfo: nil, animated: true ) @@ -631,247 +647,106 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi return true } - func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - return nil + func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) { + UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView) } - func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) { + UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView) + } + + func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section] - let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500) + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] switch section.model { - case .messageRequests: - let hide: UIContextualAction = UIContextualAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _, completionHandler in - Storage.shared.write { db in db[.hasHiddenMessageRequests] = true } - completionHandler(true) - } - hide.themeBackgroundColor = .conversationButton_swipeDestructive - - return UISwipeActionsConfiguration(actions: [hide]) - case .threads: - let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] - guard threadViewModel.interactionVariant != .infoClosedGroupCurrentUserLeaving else { return nil } - - let pin: UIContextualAction = UIContextualAction( - title: (threadViewModel.threadIsPinned ? "UNPIN_BUTTON_TEXT".localized() : "PIN_BUTTON_TEXT".localized()), - icon: UIImage(systemName: "pin"), - iconHeight: Values.mediumFontSize, - themeTintColor: .white, - themeBackgroundColor: .conversationButton_swipeDestructive, - side: .trailing, - actionIndex: 0, - indexPath: indexPath, - tableView: tableView - ) { _, _, completionHandler in - (tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate( - isPinned: !threadViewModel.threadIsPinned + // Cannot properly sync outgoing blinded message requests so don't provide the option + guard + threadViewModel.threadVariant != .contact || + SessionId(from: section.elements[indexPath.row].threadId)?.prefix == .standard + else { return nil } + + return UIContextualAction.configuration( + for: UIContextualAction.generateSwipeActions( + [.toggleReadStatus], + for: .leading, + indexPath: indexPath, + tableView: tableView, + threadViewModel: threadViewModel, + viewController: self ) - completionHandler(true) - - // Delay the change to give the cell "unswipe" animation some time to complete - DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { - Storage.shared.writeAsync { db in - try SessionThread - .filter(id: threadViewModel.threadId) - .updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned)) - } - } - } - pin.themeBackgroundColor = .conversationButton_swipeTertiary - - let mute: UIContextualAction = UIContextualAction( - title: ((threadViewModel.threadMutedUntilTimestamp != nil) ? "unmute_button_text".localized() : "mute_button_text".localized()), - icon: UIImage(systemName: "speaker.slash"), - iconHeight: Values.mediumFontSize, - themeTintColor: .white, - themeBackgroundColor: .conversationButton_swipeDestructive, - side: .trailing, - actionIndex: 1, - indexPath: indexPath, - tableView: tableView - ) { _, _, completionHandler in - (tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate( - isMuted: !(threadViewModel.threadMutedUntilTimestamp != nil) - ) - completionHandler(true) - - // Delay the change to give the cell "unswipe" animation some time to complete - DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { - Storage.shared.writeAsync { db in - let currentValue: TimeInterval? = try SessionThread - .filter(id: threadViewModel.threadId) - .select(.mutedUntilTimestamp) - .asRequest(of: TimeInterval.self) - .fetchOne(db) - - try SessionThread - .filter(id: threadViewModel.threadId) - .updateAll( - db, - SessionThread.Columns.mutedUntilTimestamp.set( - to: (currentValue == nil ? - Date.distantFuture.timeIntervalSince1970 : - nil - ) - ) - ) - } - } - } - mute.themeBackgroundColor = .conversationButton_swipeSecondary - - switch (threadViewModel.threadVariant, threadViewModel.currentUserIsClosedGroupMember) { - case (.contact, _): - let delete: UIContextualAction = UIContextualAction( - title: "TXT_DELETE_TITLE".localized(), - icon: UIImage(named: "icon_bin")?.resizedImage(to: CGSize(width: Values.mediumFontSize, height: Values.mediumFontSize)), - iconHeight: Values.mediumFontSize, - themeTintColor: .white, - themeBackgroundColor: .conversationButton_swipeDestructive, - side: .trailing, - actionIndex: 2, - indexPath: indexPath, - tableView: tableView - ) { [weak self] _, _, completionHandler in - let confirmationModalExplanation: NSAttributedString = { - let mutableAttributedString = NSMutableAttributedString( - string: String( - format: "delete_conversation_confirmation_alert_message".localized(), - threadViewModel.displayName - ) - ) - mutableAttributedString.addAttribute( - .font, - value: UIFont.boldSystemFont(ofSize: Values.smallFontSize), - range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName) - ) - return mutableAttributedString - }() - - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "delete_conversation_confirmation_alert_title".localized(), - body: .attributedText(confirmationModalExplanation), - confirmTitle: "TXT_DELETE_TITLE".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - dismissOnConfirm: true, - onConfirm: { [weak self] _ in - self?.viewModel.delete( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant - ) - self?.dismiss(animated: true, completion: nil) - - completionHandler(true) - }, - afterClosed: { completionHandler(false) } - ) - ) - - self?.present(confirmationModal, animated: true, completion: nil) - } - delete.themeBackgroundColor = .conversationButton_swipeDestructive - - return UISwipeActionsConfiguration(actions: [ delete, mute, pin ]) - - case (.closedGroup, false): - let delete: UIContextualAction = UIContextualAction( - title: "TXT_DELETE_TITLE".localized(), - icon: UIImage(named: "icon_bin")?.resizedImage(to: CGSize(width: Values.mediumFontSize, height: Values.mediumFontSize)), - iconHeight: Values.mediumFontSize, - themeTintColor: .white, - themeBackgroundColor: .conversationButton_swipeDestructive, - side: .trailing, - actionIndex: 2, - indexPath: indexPath, - tableView: tableView - ) { [weak self] _, _, completionHandler in - self?.viewModel.delete( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - force: true - ) - - completionHandler(true) - } - - return UISwipeActionsConfiguration(actions: [ delete, mute, pin ]) - - default: - let leave: UIContextualAction = UIContextualAction( - title: "LEAVE_BUTTON_TITLE".localized(), - icon: UIImage(systemName: "rectangle.portrait.and.arrow.right"), - iconHeight: Values.mediumFontSize, - themeTintColor: .white, - themeBackgroundColor: .conversationButton_swipeDestructive, - side: .trailing, - actionIndex: 2, - indexPath: indexPath, - tableView: tableView - ) { [weak self] _, _, completionHandler in - let confirmationModalTitle: String = (threadViewModel.threadVariant == .closedGroup) ? - "leave_group_confirmation_alert_title".localized() : - "leave_community_confirmation_alert_title".localized() - - let confirmationModalExplanation: NSAttributedString = { - if threadViewModel.threadVariant == .closedGroup && threadViewModel.currentUserIsClosedGroupAdmin == true { - return NSAttributedString(string: "admin_group_leave_warning".localized()) - } - - let mutableAttributedString = NSMutableAttributedString( - string: String( - format: "leave_community_confirmation_alert_message".localized(), - threadViewModel.displayName - ) - ) - mutableAttributedString.addAttribute( - .font, - value: UIFont.boldSystemFont(ofSize: Values.smallFontSize), - range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName) - ) - return mutableAttributedString - }() - - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: confirmationModalTitle, - body: .attributedText(confirmationModalExplanation), - confirmTitle: "LEAVE_BUTTON_TITLE".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - dismissOnConfirm: true, - onConfirm: { [weak self] _ in - self?.viewModel.delete( - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant - ) - self?.dismiss(animated: true, completion: nil) - - completionHandler(true) - }, - afterClosed: { completionHandler(false) } - ) - ) - - self?.present(confirmationModal, animated: true, completion: nil) - } - leave.themeBackgroundColor = .conversationButton_swipeDestructive - - return UISwipeActionsConfiguration(actions: [ leave, mute, pin ]) - } + ) default: return nil } } - func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) { - UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView) - } + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section] + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] - func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) { - UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView) + switch section.model { + case .messageRequests: + return UIContextualAction.configuration( + for: UIContextualAction.generateSwipeActions( + [.hide], + for: .trailing, + indexPath: indexPath, + tableView: tableView, + threadViewModel: threadViewModel, + viewController: self + ) + ) + + case .threads: + let sessionIdPrefix: SessionId.Prefix? = SessionId(from: threadViewModel.threadId)?.prefix + + // Cannot properly sync outgoing blinded message requests so only provide valid options + let shouldHavePinAction: Bool = ( + sessionIdPrefix != .blinded15 && + sessionIdPrefix != .blinded25 + ) + let shouldHaveMuteAction: Bool = { + switch threadViewModel.threadVariant { + case .contact: return ( + !threadViewModel.threadIsNoteToSelf && + sessionIdPrefix != .blinded15 && + sessionIdPrefix != .blinded25 + ) + + case .legacyGroup, .group: return ( + threadViewModel.currentUserIsClosedGroupMember == true + ) + + case .community: return true + } + }() + let destructiveAction: UIContextualAction.SwipeAction = { + switch (threadViewModel.threadVariant, threadViewModel.threadIsNoteToSelf, threadViewModel.currentUserIsClosedGroupMember) { + case (.contact, true, _): return .hide + case (.legacyGroup, _, true), (.group, _, true), (.community, _, _): return .leave + default: return .delete + } + }() + + return UIContextualAction.configuration( + for: UIContextualAction.generateSwipeActions( + [ + (!shouldHavePinAction ? nil : .pin), + (!shouldHaveMuteAction ? nil : .mute), + destructiveAction + ].compactMap { $0 }, + for: .trailing, + indexPath: indexPath, + tableView: tableView, + threadViewModel: threadViewModel, + viewController: self + ) + ) + + default: return nil + } } // MARK: - Interaction @@ -887,7 +762,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi variant: SessionThread.Variant, isMessageRequest: Bool, with action: ConversationViewModel.Action, - focusedInteractionId: Int64?, + focusedInteractionInfo: Interaction.TimestampInfo?, animated: Bool ) { if let presentedVC = self.presentedViewController { @@ -900,7 +775,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi ConversationVC( threadId: threadId, threadVariant: variant, - focusedInteractionId: focusedInteractionId + focusedInteractionInfo: focusedInteractionInfo ) ].compactMap { $0 } diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 7effafa61..9ce940a93 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import DifferenceKit import SignalUtilitiesKit +import SessionMessagingKit import SessionUtilitiesKit public class HomeViewModel { @@ -19,38 +20,45 @@ public class HomeViewModel { // MARK: - Variables - public static let pageSize: Int = 15 + public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 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 - } + let userProfile: Profile } // MARK: - Initialization init() { - self.state = State() + typealias InitialData = ( + showViewedSeedBanner: Bool, + hasHiddenMessageRequests: Bool, + profile: Profile + ) + + let initialData: InitialData? = Storage.shared.read { db -> InitialData in + ( + !db[.hasViewedSeed], + db[.hasHiddenMessageRequests], + Profile.fetchOrCreateCurrentUser(db) + ) + } + + self.state = State( + showViewedSeedBanner: (initialData?.showViewedSeedBanner ?? true), + hasHiddenMessageRequests: (initialData?.hasHiddenMessageRequests ?? false), + unreadMessageRequestThreadCount: 0, + userProfile: (initialData?.profile ?? Profile.fetchOrCreateCurrentUser()) + ) 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 userPublicKey: String = self.state.userProfile.id let thread: TypedTableAlias = TypedTableAlias() self.pagedDataObserver = PagedDatabaseObserver( pagedTable: SessionThread.self, @@ -62,9 +70,10 @@ public class HomeViewModel { columns: [ .id, .shouldBeVisible, - .isPinned, + .pinnedPriority, .mutedUntilTimestamp, - .onlyNotifyForMentions + .onlyNotifyForMentions, + .markedAsUnread ] ), PagedData.ObservedChanges( @@ -76,7 +85,7 @@ public class HomeViewModel { joinToPagedType: { let interaction: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])") + return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])") }() ), PagedData.ObservedChanges( @@ -85,7 +94,7 @@ public class HomeViewModel { joinToPagedType: { let contact: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])") + return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])") }() ), PagedData.ObservedChanges( @@ -93,8 +102,53 @@ public class HomeViewModel { columns: [.name, .nickname, .profilePictureFileName], joinToPagedType: { let profile: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let threadVariants: [SessionThread.Variant] = [.legacyGroup, .group] + let targetRole: GroupMember.Role = GroupMember.Role.standard - return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])") + return SQL(""" + JOIN \(Profile.self) ON ( + ( -- Contact profile change + \(profile[.id]) = \(thread[.id]) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) + ) OR ( -- Closed group profile change + \(SQL("\(thread[.variant]) IN \(threadVariants)")) AND ( + profile.id = ( -- Front profile + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.groupId]) = \(thread[.id]) AND + \(SQL("\(groupMember[.role]) = \(targetRole)")) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) OR + profile.id = ( -- Back profile + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.groupId]) = \(thread[.id]) AND + \(SQL("\(groupMember[.role]) = \(targetRole)")) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) OR ( -- Fallback profile + profile.id = \(userPublicKey) AND + ( + SELECT COUNT(\(groupMember[.profileId])) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.groupId]) = \(thread[.id]) AND + \(SQL("\(groupMember[.role]) = \(targetRole)")) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) = 1 + ) + ) + ) + ) + """) }() ), PagedData.ObservedChanges( @@ -103,7 +157,7 @@ public class HomeViewModel { joinToPagedType: { let closedGroup: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])") + return SQL("JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])") }() ), PagedData.ObservedChanges( @@ -112,7 +166,7 @@ public class HomeViewModel { joinToPagedType: { let openGroup: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])") + return SQL("JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])") }() ), PagedData.ObservedChanges( @@ -123,8 +177,8 @@ public class HomeViewModel { let recipientState: TypedTableAlias = TypedTableAlias() return """ - LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id]) + JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id]) """ }() ), @@ -134,7 +188,7 @@ public class HomeViewModel { joinToPagedType: { let typingIndicator: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])") + return SQL("JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])") }() ) ], @@ -155,15 +209,22 @@ public class HomeViewModel { currentDataRetriever: { self?.threadData }, onDataChange: self?.onThreadChange, onUnobservedDataChange: { updatedData, changeset in - self?.unobservedThreadDataChanges = (updatedData, changeset) + self?.unobservedThreadDataChanges = (changeset.isEmpty ? + nil : + (updatedData, changeset) + ) } ) + + self?.hasReceivedInitialThreadData = true } ) - // 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) + // Run the initial query on a background thread so we don't block the main thread + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + // The `.pageBefore` will query from a `0` offset loading the first page + self?.pagedDataObserver?.load(.pageBefore) + } } // MARK: - State @@ -181,6 +242,7 @@ public class HomeViewModel { public lazy var observableState = ValueObservation .trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) } .removeDuplicates() + .handleEvents(didFail: { SNLog("[HomeViewModel] Observation failed with error: \($0)") }) private static func retrieveState(_ db: Database) throws -> State { let hasViewedSeed: Bool = db[.hasViewedSeed] @@ -203,8 +265,10 @@ public class HomeViewModel { let oldState: State = self.state self.state = updatedState - // If the messageRequest content changed then we need to re-process the thread data + // If the messageRequest content changed then we need to re-process the thread data (assuming + // we've received the initial thread data) guard + self.hasReceivedInitialThreadData, ( oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests || oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount @@ -215,7 +279,7 @@ public class HomeViewModel { /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above let currentData: [SectionModel] = (self.unobservedThreadDataChanges?.0 ?? self.threadData) let updatedThreadData: [SectionModel] = self.process( - data: currentData.flatMap { $0.elements }, + data: (currentData.first(where: { $0.model == .threads })?.elements ?? []), for: currentPageInfo ) @@ -223,14 +287,18 @@ public class HomeViewModel { updatedData: updatedThreadData, currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) }, onDataChange: onThreadChange, - onUnobservedDataChange: { [weak self] updatedThreadData, changeset in - self?.unobservedThreadDataChanges = (updatedThreadData, changeset) + onUnobservedDataChange: { [weak self] updatedData, changeset in + self?.unobservedThreadDataChanges = (changeset.isEmpty ? + nil : + (updatedData, changeset) + ) } ) } // MARK: - Thread Data + private var hasReceivedInitialThreadData: Bool = false public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? public private(set) var threadData: [SectionModel] = [] public private(set) var pagedDataObserver: PagedDatabaseObserver? @@ -239,8 +307,14 @@ public class HomeViewModel { 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], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { - onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1) + if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { + let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange + + switch Thread.isMainThread { + case true: performChange?(changes.0, changes.1) + case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) } + } + self.unobservedThreadDataChanges = nil } } @@ -264,7 +338,10 @@ public class HomeViewModel { [SectionModel( section: .messageRequests, elements: [ - SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount)) + SessionThreadViewModel( + threadId: SessionThreadViewModel.messageRequestsSectionId, + unreadCount: UInt(finalUnreadMessageRequestCount) + ) ] )] ), @@ -272,18 +349,25 @@ public class HomeViewModel { SectionModel( section: .threads, elements: data - .filter { $0.id != SessionThreadViewModel.invalidId } + .filter { threadViewModel in + threadViewModel.id != SessionThreadViewModel.invalidId && + threadViewModel.id != SessionThreadViewModel.messageRequestsSectionId + } .sorted { lhs, rhs -> Bool in - if lhs.threadIsPinned && !rhs.threadIsPinned { return true } - if !lhs.threadIsPinned && rhs.threadIsPinned { return false } + guard lhs.threadPinnedPriority == rhs.threadPinnedPriority else { + return lhs.threadPinnedPriority > rhs.threadPinnedPriority + } return lhs.lastInteractionDate > rhs.lastInteractionDate } .map { viewModel -> SessionThreadViewModel in - viewModel.populatingCurrentUserBlindedKey( - currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]? + viewModel.populatingCurrentUserBlindedKeys( + currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]? .first? - .currentUserBlindedPublicKey + .currentUserBlinded15PublicKey, + currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]? + .first? + .currentUserBlinded25PublicKey ) } ) @@ -298,32 +382,4 @@ public class HomeViewModel { public func updateThreadData(_ updatedData: [SectionModel]) { self.threadData = updatedData } - - // MARK: - Functions - - public func delete(threadId: String, threadVariant: SessionThread.Variant, force: Bool = false) { - - func delete(_ db: Database, threadId: String) throws { - _ = try SessionThread - .filter(id: threadId) - .deleteAll(db) - } - - Storage.shared.writeAsync { db in - switch (threadVariant, force) { - case (.closedGroup, false): - try MessageSender.leave( - db, - groupPublicKey: threadId, - deleteThread: true - ) - - case (.openGroup, _): - OpenGroupManager.shared.delete(db, openGroupId: threadId) - - default: - try delete(db, threadId: threadId) - } - } - } } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 180b47761..6b91630da 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -7,16 +7,19 @@ import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit -class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { +class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource { private static let loadingHeaderHeight: CGFloat = 40 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: - SessionUtilRespondingViewController + + let isConversationList: Bool = true + // MARK: - Intialization init() { @@ -103,6 +106,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat result.translatesAutoresizingMaskIntoConstraints = false result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal) result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside) + result.accessibilityIdentifier = "Clear all" return result }() @@ -157,8 +161,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -169,8 +172,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } // MARK: - Layout @@ -219,6 +221,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } } + private func stopObservingChanges() { + self.viewModel.onThreadChange = nil + } + private func handleThreadUpdates( _ updatedData: [MessageRequestsViewModel.SectionModel], changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>, @@ -227,9 +233,18 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat // 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, changeset: changeset, initialLoad: true) + // Hide the 'loading conversations' label (now that we have received conversation data) + loadingConversationsLabel.isHidden = true + + // Show the empty state if there is no data + clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false) + emptyStateLabel.isHidden = !clearAllButton.isHidden + + // Update the content + viewModel.updateThreadData(updatedData) + tableView.reloadData() + hasLoadedInitialThreadData = true } return } @@ -266,7 +281,11 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } private func autoLoadNextPageIfNeeded() { - guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } + guard + self.hasLoadedInitialThreadData && + !self.isAutoLoadingNextPage && + !self.isLoadingMore + else { return } self.isAutoLoadingNextPage = true @@ -393,31 +412,33 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat return true } + func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) { + UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView) + } + + func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) { + UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView) + } + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section] + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] switch section.model { case .threads: - let threadId: String = section.elements[indexPath.row].threadId - let delete: UIContextualAction = UIContextualAction( - style: .destructive, - title: "TXT_DELETE_TITLE".localized() - ) { [weak self] _, _, completionHandler in - self?.delete(threadId) - completionHandler(true) - } - delete.themeBackgroundColor = .conversationButton_swipeDestructive - - let block: UIContextualAction = UIContextualAction( - style: .normal, - title: "BLOCK_LIST_BLOCK_BUTTON".localized() - ) { [weak self] _, _, completionHandler in - self?.block(threadId) - completionHandler(true) - } - block.themeBackgroundColor = .conversationButton_swipeSecondary - - return UISwipeActionsConfiguration(actions: [ delete, block ]) + return UIContextualAction.configuration( + for: UIContextualAction.generateSwipeActions( + [ + (threadViewModel.threadVariant != .contact ? nil : .block), + .delete + ].compactMap { $0 }, + for: .trailing, + indexPath: indexPath, + tableView: tableView, + threadViewModel: threadViewModel, + viewController: self + ) + ) default: return nil } @@ -430,9 +451,16 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat return } - let threadIds: [String] = (viewModel.threadData + let contactThreadIds: [String] = (viewModel.threadData .first { $0.model == .threads }? .elements + .filter { $0.threadVariant == .contact } + .map { $0.threadId }) + .defaulting(to: []) + let groupThreadIds: [String] = (viewModel.threadData + .first { $0.model == .threads }? + .elements + .filter { $0.threadVariant == .legacyGroup || $0.threadVariant == .group } .map { $0.threadId }) .defaulting(to: []) let alertVC: UIAlertController = UIAlertController( @@ -444,72 +472,14 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(), style: .destructive ) { _ in - // Clear the requests - Storage.shared.write { db in - _ = try SessionThread - .filter(ids: threadIds) - .deleteAll(db) - } + MessageRequestsViewModel.clearAllRequests( + contactThreadIds: contactThreadIds, + groupThreadIds: groupThreadIds + ) }) alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil)) Modal.setupForIPadIfNeeded(alertVC, targetView: self.view) 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) - } - }) - - alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil)) - - Modal.setupForIPadIfNeeded(alertVC, targetView: self.view) - self.present(alertVC, animated: true, completion: nil) - } - - private func block(_ threadId: String) { - let alertVC: UIAlertController = UIAlertController( - title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(), - message: nil, - preferredStyle: .actionSheet - ) - alertVC.addAction(UIAlertAction( - title: "BLOCK_LIST_BLOCK_BUTTON".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)) - - Modal.setupForIPadIfNeeded(alertVC, targetView: self.view) - self.present(alertVC, animated: true, completion: nil) - } } diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index cd0dbf18d..08e6eff32 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -17,7 +17,7 @@ public class MessageRequestsViewModel { // MARK: - Variables - public static let pageSize: Int = 15 + public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15) // MARK: - Initialization @@ -51,7 +51,7 @@ public class MessageRequestsViewModel { joinToPagedType: { let interaction: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])") + return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])") }() ), PagedData.ObservedChanges( @@ -60,7 +60,7 @@ public class MessageRequestsViewModel { joinToPagedType: { let contact: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])") + return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])") }() ), PagedData.ObservedChanges( @@ -69,7 +69,7 @@ public class MessageRequestsViewModel { joinToPagedType: { let profile: TypedTableAlias = TypedTableAlias() - return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])") + return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])") }() ), PagedData.ObservedChanges( @@ -80,8 +80,8 @@ public class MessageRequestsViewModel { let recipientState: TypedTableAlias = TypedTableAlias() return """ - LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id]) + JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id]) """ }() ) @@ -103,7 +103,10 @@ public class MessageRequestsViewModel { currentDataRetriever: { self?.threadData }, onDataChange: self?.onThreadChange, onUnobservedDataChange: { updatedData, changeset in - self?.unobservedThreadDataChanges = (updatedData, changeset) + self?.unobservedThreadDataChanges = (changeset.isEmpty ? + nil : + (updatedData, changeset) + ) } ) } @@ -126,8 +129,14 @@ public class MessageRequestsViewModel { 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], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { - self.onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1) + if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges { + let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange + + switch Thread.isMainThread { + case true: performChange?(changes.0, changes.1) + case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) } + } + self.unobservedThreadDataChanges = nil } } @@ -147,10 +156,13 @@ public class MessageRequestsViewModel { elements: data .sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate } .map { viewModel -> SessionThreadViewModel in - viewModel.populatingCurrentUserBlindedKey( - currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]? + viewModel.populatingCurrentUserBlindedKeys( + currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]? .first? - .currentUserBlindedPublicKey + .currentUserBlinded15PublicKey, + currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]? + .first? + .currentUserBlinded25PublicKey ) } ) @@ -165,4 +177,32 @@ public class MessageRequestsViewModel { public func updateThreadData(_ updatedData: [SectionModel]) { self.threadData = updatedData } + + // MARK: - Functions + + static func clearAllRequests( + contactThreadIds: [String], + groupThreadIds: [String] + ) { + // Clear the requests + Storage.shared.write { db in + // Remove the one-to-one requests + try SessionThread.deleteOrLeave( + db, + threadIds: contactThreadIds, + threadVariant: .contact, + groupLeaveType: .silent, + calledFromConfigHandling: false + ) + + // Remove the group requests + try SessionThread.deleteOrLeave( + db, + threadIds: groupThreadIds, + threadVariant: .group, + groupLeaveType: .silent, + calledFromConfigHandling: false + ) + } + } } diff --git a/Session/Home/Message Requests/Views/MessageRequestsCell.swift b/Session/Home/Message Requests/Views/MessageRequestsCell.swift index 5141500eb..61feef7b3 100644 --- a/Session/Home/Message Requests/Views/MessageRequestsCell.swift +++ b/Session/Home/Message Requests/Views/MessageRequestsCell.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SignalUtilitiesKit class MessageRequestsCell: UITableViewCell { static let reuseIdentifier = "MessageRequestsCell" @@ -29,7 +30,7 @@ class MessageRequestsCell: UITableViewCell { result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true result.themeBackgroundColor = .conversationButton_unreadBubbleBackground - result.layer.cornerRadius = (Values.mediumProfilePictureSize / 2) + result.layer.cornerRadius = (ProfilePictureView.Size.list.viewSize / 2) return result }() @@ -100,8 +101,8 @@ class MessageRequestsCell: UITableViewCell { constant: (Values.accentLineThickness + Values.mediumSpacing) ), iconContainerView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - iconContainerView.widthAnchor.constraint(equalToConstant: Values.mediumProfilePictureSize), - iconContainerView.heightAnchor.constraint(equalToConstant: Values.mediumProfilePictureSize), + iconContainerView.widthAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize), + iconContainerView.heightAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize), iconImageView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor), iconImageView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor), diff --git a/Session/Home/New Conversation/NewConversationVC.swift b/Session/Home/New Conversation/NewConversationVC.swift index 0e739dbfe..34b78e82f 100644 --- a/Session/Home/New Conversation/NewConversationVC.swift +++ b/Session/Home/New Conversation/NewConversationVC.swift @@ -2,9 +2,9 @@ import UIKit import GRDB -import PromiseKit import SessionUIKit import SessionMessagingKit +import SignalUtilitiesKit final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UITableViewDataSource { private let newConversationViewModel = NewConversationViewModel() @@ -143,13 +143,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI cell.update( with: SessionCell.Info( id: profile, - leftAccessory: .profile(profile.id, profile), - title: profile.displayName() - ), - style: .edgeToEdge, - position: Position.with( - indexPath.row, - count: newConversationViewModel.sectionData[indexPath.section].contacts.count + position: Position.with( + indexPath.row, + count: newConversationViewModel.sectionData[indexPath.section].contacts.count + ), + leftAccessory: .profile(id: profile.id, profile: profile), + title: profile.displayName(), + styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge) ) ) @@ -179,15 +179,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI tableView.deselectRow(at: indexPath, animated: true) let sessionId = newConversationViewModel.sectionData[indexPath.section].contacts[indexPath.row].id - let maybeThread: SessionThread? = Storage.shared.write { db in - try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact) - } - guard maybeThread != nil else { return } - - self.navigationController?.dismiss(animated: true, completion: nil) - - SessionApp.presentConversation(for: sessionId, action: .compose, animated: false) + SessionApp.presentConversationCreatingIfNeeded( + for: sessionId, + variant: .contact, + dismissing: navigationController, + animated: false + ) } func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { diff --git a/Session/Home/New Conversation/NewDMVC.swift b/Session/Home/New Conversation/NewDMVC.swift index bb82d716a..e4765fee1 100644 --- a/Session/Home/New Conversation/NewDMVC.swift +++ b/Session/Home/New Conversation/NewDMVC.swift @@ -3,7 +3,6 @@ import UIKit import AVFoundation import GRDB -import Curve25519Kit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit @@ -166,16 +165,45 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle dismiss(animated: true, completion: nil) } - func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String) { + func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String, onError: (() -> ())?) { let hexEncodedPublicKey = string - startNewDMIfPossible(with: hexEncodedPublicKey) + startNewDMIfPossible(with: hexEncodedPublicKey, onError: onError) } - fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) { + fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String, onError: (() -> ())?) { let maybeSessionId: SessionId? = SessionId(from: onsNameOrPublicKey) - if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) && maybeSessionId?.prefix == .standard { - startNewDM(with: onsNameOrPublicKey) + if KeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) { + switch maybeSessionId?.prefix { + case .standard: + startNewDM(with: onsNameOrPublicKey) + + case .blinded15, .blinded25: + let modal: ConfirmationModal = ConfirmationModal( + targetView: self.view, + info: ConfirmationModal.Info( + title: "ALERT_ERROR_TITLE".localized(), + body: .text("DM_ERROR_DIRECT_BLINDED_ID".localized()), + cancelTitle: "BUTTON_OK".localized(), + cancelStyle: .alert_text, + afterClosed: onError + ) + ) + self.present(modal, animated: true) + + default: + let modal: ConfirmationModal = ConfirmationModal( + targetView: self.view, + info: ConfirmationModal.Info( + title: "ALERT_ERROR_TITLE".localized(), + body: .text("DM_ERROR_INVALID".localized()), + cancelTitle: "BUTTON_OK".localized(), + cancelStyle: .alert_text, + afterClosed: onError + ) + ) + self.present(modal, animated: true) + } return } @@ -183,58 +211,64 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle ModalActivityIndicatorViewController .present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in SnodeAPI - .getSessionID(for: onsNameOrPublicKey) - .done { sessionID in + .getSessionID(for: onsNameOrPublicKey) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + modalActivityIndicator.dismiss { + var messageOrNil: String? + if let error = error as? SnodeAPIError { + switch error { + case .decryptionFailed, .hashingFailed, .validationFailed: + messageOrNil = error.errorDescription + default: break + } + } + let message: String = { + if let messageOrNil: String = messageOrNil { + return messageOrNil + } + + return (maybeSessionId?.prefix == .blinded15 || maybeSessionId?.prefix == .blinded25 ? + "DM_ERROR_DIRECT_BLINDED_ID".localized() : + "DM_ERROR_INVALID".localized() + ) + }() + + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: "ALERT_ERROR_TITLE".localized(), + body: .text(message), + cancelTitle: "BUTTON_OK".localized(), + cancelStyle: .alert_text, + afterClosed: onError + ) + ) + self?.present(modal, animated: true) + } + } + }, + receiveValue: { 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: String = { - if let messageOrNil: String = messageOrNil { - return messageOrNil - } - - return (maybeSessionId?.prefix == .blinded ? - "DM_ERROR_DIRECT_BLINDED_ID".localized() : - "DM_ERROR_INVALID".localized() - ) - }() - - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: "ALERT_ERROR_TITLE".localized(), - body: .text(message), - cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) + self?.startNewDM(with: 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) - - SessionApp.presentConversation(for: sessionId, action: .compose, animated: false) + SessionApp.presentConversationCreatingIfNeeded( + for: sessionId, + variant: .contact, + dismissing: presentingViewController, + animated: false + ) } } @@ -632,7 +666,7 @@ private final class EnterPublicKeyVC: UIViewController { @objc fileprivate func startNewDMIfPossible() { let text = publicKeyTextView.text?.trimmingCharacters(in: .whitespaces) ?? "" - NewDMVC.startNewDMIfPossible(with: text) + NewDMVC.startNewDMIfPossible(with: text, onError: nil) } } diff --git a/Session/Media Viewing & Editing/AllMediaViewController.swift b/Session/Media Viewing & Editing/AllMediaViewController.swift index 8e3cbba03..a9e09d488 100644 --- a/Session/Media Viewing & Editing/AllMediaViewController.swift +++ b/Session/Media Viewing & Editing/AllMediaViewController.swift @@ -6,6 +6,7 @@ import GRDB import DifferenceKit import SessionUIKit import SignalUtilitiesKit +import SignalCoreKit public class AllMediaViewController: UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate { private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) diff --git a/Session/Media Viewing & Editing/CropScaleImageViewController.swift b/Session/Media Viewing & Editing/CropScaleImageViewController.swift index f6ad5d8ff..df821b716 100644 --- a/Session/Media Viewing & Editing/CropScaleImageViewController.swift +++ b/Session/Media Viewing & Editing/CropScaleImageViewController.swift @@ -1,11 +1,10 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation import MediaPlayer import SessionUIKit import SignalUtilitiesKit +import SignalCoreKit // This kind of view is tricky. I've tried to organize things in the // simplest possible way. @@ -32,7 +31,7 @@ import SignalUtilitiesKit let srcImage: UIImage - let successCompletion: ((UIImage) -> Void) + let successCompletion: ((Data) -> Void) var imageView: UIView! @@ -79,7 +78,7 @@ import SignalUtilitiesKit notImplemented() } - @objc required init(srcImage: UIImage, successCompletion : @escaping (UIImage) -> Void) { + @objc required init(srcImage: UIImage, successCompletion : @escaping (Data) -> Void) { // normalized() can be slightly expensive but in practice this is fine. self.srcImage = srcImage.normalized() self.successCompletion = successCompletion @@ -487,10 +486,9 @@ import SignalUtilitiesKit @objc func donePressed(sender: UIButton) { let successCompletion = self.successCompletion dismiss(animated: true, completion: { - guard let dstImage = self.generateDstImage() else { - return - } - successCompletion(dstImage) + guard let dstImageData: Data = self.generateDstImageData() else { return } + + successCompletion(dstImageData) }) } @@ -517,4 +515,8 @@ import SignalUtilitiesKit UIGraphicsEndImageContext() return scaledImage } + + func generateDstImageData() -> Data? { + return generateDstImage().map { $0.pngData() } + } } diff --git a/Session/Media Viewing & Editing/DocumentTitleViewController.swift b/Session/Media Viewing & Editing/DocumentTitleViewController.swift index bff7c7597..d7a84d87e 100644 --- a/Session/Media Viewing & Editing/DocumentTitleViewController.swift +++ b/Session/Media Viewing & Editing/DocumentTitleViewController.swift @@ -6,6 +6,7 @@ import GRDB import DifferenceKit import SessionUIKit import SignalUtilitiesKit +import SignalCoreKit public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { @@ -152,7 +153,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, } private func autoLoadNextPageIfNeeded() { - guard !self.isAutoLoadingNextPage else { return } + guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return } self.isAutoLoadingNextPage = true @@ -203,11 +204,11 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, // 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.tableView.reloadData() + self.hasLoadedInitialData = true self.performInitialScrollIfNeeded() } return @@ -499,7 +500,7 @@ class DocumentCell: UITableViewCell { func update(with item: MediaGalleryViewModel.Item) { let attachment = item.attachment titleLabel.text = (attachment.sourceFilename ?? "File") - detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))" + detailLabel.text = "\(Format.fileSize(attachment.byteCount)))" timeLabel.text = Date( timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000) ).formattedForDisplay diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift index 37b0b2e15..0967e8020 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerCell.swift @@ -1,12 +1,10 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit -import SignalUtilitiesKit -import SignalUtilitiesKit +import Combine import YYImage +import SignalUtilitiesKit +import SignalCoreKit class GifPickerCell: UICollectionViewCell { @@ -222,7 +220,7 @@ class GifPickerCell: UICollectionViewCell { self.themeBackgroundColor = nil if self.isCellSelected { - let activityIndicator = UIActivityIndicatorView(style: .gray) + let activityIndicator = UIActivityIndicatorView(style: .medium) self.activityIndicator = activityIndicator addSubview(activityIndicator) activityIndicator.autoCenterInSuperview() @@ -245,29 +243,27 @@ class GifPickerCell: UICollectionViewCell { } } - public func requestRenditionForSending() -> Promise { + public func requestRenditionForSending() -> AnyPublisher { guard let renditionForSending = self.renditionForSending else { owsFailDebug("renditionForSending was unexpectedly nil") - return Promise(error: GiphyError.assertionError(description: "renditionForSending was unexpectedly nil")) + return Fail(error: GiphyError.assertionError(description: "renditionForSending was unexpectedly nil")) + .eraseToAnyPublisher() } - let (promise, resolver) = Promise.pending() - // We don't retain a handle on the asset request, since there will only ever // be one selected asset, and we never want to cancel it. - _ = GiphyDownloader.giphyDownloader.requestAsset(assetDescription: renditionForSending, - priority: .high, - success: { _, asset in - resolver.fulfill(asset) - }, - failure: { _ in - // TODO GiphyDownloader API should pass through a useful failing error - // so we can pass it through here - Logger.error("request failed") - resolver.reject(GiphyError.fetchFailure) - }) - - return promise + return GiphyDownloader.giphyDownloader + .requestAsset( + assetDescription: renditionForSending, + priority: .high + ) + .mapError { _ -> Error in + // TODO: GiphyDownloader API should pass through a useful failing error so we can pass it through here + Logger.error("request failed") + return GiphyError.fetchFailure + } + .map { asset, _ in asset } + .eraseToAnyPublisher() } private func clearViewState() { diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerLayout.swift b/Session/Media Viewing & Editing/GIFs/GifPickerLayout.swift index 2587eb5a2..41e020085 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerLayout.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerLayout.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation +import SignalCoreKit 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 69b8a016f..4b2436258 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -1,12 +1,11 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import Reachability import SignalUtilitiesKit -import PromiseKit import SessionUIKit +import SignalCoreKit class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate { @@ -36,12 +35,12 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect var activityIndicator: UIActivityIndicatorView? var hasSelectedCell: Bool = false var imageInfos = [GiphyImageInfo]() - - var reachability: Reachability? - + private let kCellReuseIdentifier = "kCellReuseIdentifier" var progressiveSearchTimer: Timer? + + private var disposables: Set = Set() // MARK: - Initialization @@ -114,7 +113,6 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect createViews() - reachability = Reachability.forInternetConnection() NotificationCenter.default.addObserver( self, selector: #selector(reachabilityChanged), @@ -219,7 +217,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect private func createErrorLabel(text: String) -> UILabel { let label: UILabel = UILabel() - label.font = .ows_mediumFont(withSize: 20) + label.font = UIFont.systemFont(ofSize: 20, weight: .medium) label.text = text label.themeTextColor = .textPrimary label.textAlignment = .center @@ -359,47 +357,53 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect public func getFileForCell(_ cell: GifPickerCell) { GiphyDownloader.giphyDownloader.cancelAllRequests() - - firstly { - cell.requestRenditionForSending() - }.done { [weak self] (asset: ProxiedContentAsset) in - guard let strongSelf = self else { - Logger.info("ignoring send, since VC was dismissed before fetching finished.") - return - } - guard let rendition = asset.assetDescription as? GiphyRendition else { - owsFailDebug("Invalid asset description.") - return - } - - let filePath = asset.filePath - guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath, - shouldDeleteOnDeallocation: false) else { - owsFailDebug("couldn't load asset.") - return - } - let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .medium) - - strongSelf.dismiss(animated: true) { - // Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs. - strongSelf.delegate?.gifPickerDidSelect(attachment: attachment) - } - }.catch { [weak self] error in - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: "GIF_PICKER_FAILURE_ALERT_TITLE".localized(), - body: .text(error.localizedDescription), - confirmTitle: CommonStrings.retryButton, - cancelTitle: CommonStrings.dismissButton, - cancelStyle: .alert_text, - onConfirm: { _ in - self?.getFileForCell(cell) + + cell + .requestRenditionForSending() + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure(let error): + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: "GIF_PICKER_FAILURE_ALERT_TITLE".localized(), + body: .text(error.localizedDescription), + confirmTitle: CommonStrings.retryButton, + cancelTitle: CommonStrings.dismissButton, + cancelStyle: .alert_text, + onConfirm: { _ in + self?.getFileForCell(cell) + } + ) + ) + self?.present(modal, animated: true) } - ) + }, + receiveValue: { [weak self] asset in + guard let rendition = asset.assetDescription as? GiphyRendition else { + owsFailDebug("Invalid asset description.") + return + } + + let filePath = asset.filePath + guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath, + shouldDeleteOnDeallocation: false) else { + owsFailDebug("couldn't load asset.") + return + } + let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .medium) + + self?.dismiss(animated: true) { + // Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs. + self?.delegate?.gifPickerDidSelect(attachment: attachment) + } + } ) - self?.present(modal, animated: true) - }.retainUntilComplete() + .store(in: &disposables) } public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { @@ -486,22 +490,31 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect assert(progressiveSearchTimer == nil) assert(searchBar.text == nil || searchBar.text?.count == 0) - GiphyAPI.sharedInstance.trending() - .done { [weak self] imageInfos in - Logger.info("showing trending") - - if imageInfos.count > 0 { - self?.imageInfos = imageInfos - self?.viewMode = .results + GiphyAPI.trending() + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + // Don't both showing error UI feedback for default "trending" results. + Logger.error("error: \(error)") + } + }, + receiveValue: { [weak self] imageInfos in + Logger.info("showing trending") + + if imageInfos.count > 0 { + self?.imageInfos = imageInfos + self?.viewMode = .results + } + else { + owsFailDebug("trending results was unexpectedly empty") + } } - else { - owsFailDebug("trending results was unexpectedly empty") - } - } - .catch { error in - // Don't both showing error UI feedback for default "trending" results. - Logger.error("error: \(error)") - } + ) + .store(in: &disposables) } private func search(query: String) { @@ -514,10 +527,21 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect lastQuery = query self.collectionView.contentOffset = CGPoint.zero - GiphyAPI.sharedInstance - .search( - query: query, - success: { [weak self] imageInfos in + GiphyAPI + .search(query: query) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure: + Logger.info("search failed.") + // TODO: Present this error to the user. + self?.viewMode = .error + } + }, + receiveValue: { [weak self] imageInfos in Logger.info("search complete") self?.imageInfos = imageInfos @@ -527,13 +551,9 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect else { self?.viewMode = .noResults } - }, - failure: { [weak self] _ in - Logger.info("search failed.") - // TODO: Present this error to the user. - self?.viewMode = .error } ) + .store(in: &disposables) } // MARK: - GifPickerLayoutDelegate diff --git a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift index 215fd222d..57ce17769 100644 --- a/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift +++ b/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift @@ -1,13 +1,11 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import AFNetworking import Foundation -import PromiseKit +import Combine import CoreServices import SignalUtilitiesKit import SessionUtilitiesKit +import SignalCoreKit // There's no UTI type for webp! enum GiphyFormat { @@ -18,13 +16,12 @@ enum GiphyError: Error { case assertionError(description: String) case fetchFailure } + extension GiphyError: LocalizedError { public var errorDescription: String? { switch self { - case .assertionError: - return NSLocalizedString("GIF_PICKER_ERROR_GENERIC", comment: "Generic error displayed when picking a GIF") - case .fetchFailure: - return NSLocalizedString("GIF_PICKER_ERROR_FETCH_FAILURE", comment: "Error displayed when there is a failure fetching a GIF from the remote service.") + case .assertionError: return "GIF_PICKER_ERROR_GENERIC".localized() + case .fetchFailure: return "GIF_PICKER_ERROR_FETCH_FAILURE".localized() } } } @@ -34,7 +31,7 @@ extension GiphyError: LocalizedError { // They vary in content size (i.e. width, height), // format (.jpg, .gif, .mp4, webp, etc.), // quality, etc. -@objc class GiphyRendition: ProxiedContentAssetDescription { +class GiphyRendition: ProxiedContentAssetDescription { let format: GiphyFormat let name: String let width: UInt @@ -93,7 +90,7 @@ extension GiphyError: LocalizedError { } // Represents a single Giphy image. -@objc class GiphyImageInfo: NSObject { +class GiphyImageInfo: NSObject { let giphyId: String let renditions: [GiphyRendition] // We special-case the "original" rendition because it is the @@ -267,115 +264,109 @@ extension GiphyError: LocalizedError { } } -@objc class GiphyAPI: NSObject { +enum GiphyAPI { + private static let kGiphyBaseURL = "https://api.giphy.com" + private static let urlSession: URLSession = { + let configuration: URLSessionConfiguration = ContentProxy.sessionConfiguration() + + // Don't use any caching to protect privacy of these requests. + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringCacheData + + return URLSession(configuration: configuration) + }() - // MARK: - Properties - - static let sharedInstance = GiphyAPI() - - // Force usage as a singleton - override private init() { - super.init() - - SwiftSingletons.register(self) - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - private let kGiphyBaseURL = "https://api.giphy.com/" - - private func giphyAPISessionManager() -> AFHTTPSessionManager? { - return AFHTTPSessionManager(baseURL: URL(string: kGiphyBaseURL), sessionConfiguration: .ephemeral) - } - - // MARK: Search - // This is the Signal iOS API key. - let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc" - let kGiphyPageSize = 20 + // MARK: - Search - public func trending() -> Promise<[GiphyImageInfo]> { - guard let sessionManager = giphyAPISessionManager() else { - Logger.error("Couldn't create session manager.") - return Promise.value([]) - } + // This is the Signal iOS API key. + private static let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc" + private static let kGiphyPageSize = 20 + + public static func trending() -> AnyPublisher<[GiphyImageInfo], Error> { let urlString = "/v1/gifs/trending?api_key=\(kGiphyApiKey)&limit=\(kGiphyPageSize)" - let (promise, resolver) = Promise<[GiphyImageInfo]>.pending() - sessionManager.get(urlString, - parameters: [String: AnyObject](), - headers:nil, - progress: nil, - success: { _, value in - Logger.error("search request succeeded") - if let imageInfos = self.parseGiphyImages(responseJson: value) { - resolver.fulfill(imageInfos) - } else { - Logger.error("unable to parse trending images") - resolver.fulfill([]) - } - - }, - failure: { _, error in - Logger.error("search request failed: \(error)") - resolver.reject(error) - }) - - return promise + + guard let url: URL = URL(string: "\(kGiphyBaseURL)\(urlString)") else { + return Fail(error: HTTPError.invalidURL) + .eraseToAnyPublisher() + } + + return urlSession + .dataTaskPublisher(for: url) + .mapError { urlError in + Logger.error("search request failed: \(urlError)") + + // URLError codes are negative values + return HTTPError.generic + } + .map { data, _ in + Logger.debug("search request succeeded") + + guard let imageInfos = self.parseGiphyImages(responseData: data) else { + Logger.error("unable to parse trending images") + return [] + } + + return imageInfos + } + .eraseToAnyPublisher() } - public func search(query: String, success: @escaping (([GiphyImageInfo]) -> Void), failure: @escaping ((NSError?) -> Void)) { - guard let sessionManager = giphyAPISessionManager() else { - Logger.error("Couldn't create session manager.") - failure(nil) - return - } - guard NSURL(string: kGiphyBaseURL) != nil else { - Logger.error("Invalid base URL.") - failure(nil) - return - } - + public static func search(query: String) -> AnyPublisher<[GiphyImageInfo], Error> { let kGiphyPageOffset = 0 - guard let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - Logger.error("Could not URL encode query: \(query).") - failure(nil) - return + + guard + let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let url: URL = URL( + string: [ + kGiphyBaseURL, + "/v1/gifs/search?api_key=\(kGiphyApiKey)", + "&offset=\(kGiphyPageOffset)", + "&limit=\(kGiphyPageSize)", + "&q=\(queryEncoded)" + ].joined() + ) + else { + return Fail(error: HTTPError.invalidURL) + .eraseToAnyPublisher() } - let urlString = "/v1/gifs/search?api_key=\(kGiphyApiKey)&offset=\(kGiphyPageOffset)&limit=\(kGiphyPageSize)&q=\(queryEncoded)" - - guard ContentProxy.configureSessionManager(sessionManager: sessionManager, forUrl: urlString) else { + + var request: URLRequest = URLRequest(url: url) + + guard ContentProxy.configureProxiedRequest(request: &request) else { owsFailDebug("Could not configure query: \(query).") - failure(nil) - return + return Fail(error: HTTPError.generic) + .eraseToAnyPublisher() } - - sessionManager.get(urlString, - parameters: [String: AnyObject](), - headers: nil, - progress: nil, - success: { _, value in - Logger.error("search request succeeded") - guard let imageInfos = self.parseGiphyImages(responseJson: value) else { - failure(nil) - return - } - success(imageInfos) - }, - failure: { _, error in - Logger.error("search request failed: \(error)") - failure(error as NSError) - }) + + return urlSession + .dataTaskPublisher(for: request) + .mapError { urlError in + Logger.error("search request failed: \(urlError)") + + // URLError codes are negative values + return HTTPError.generic + } + .tryMap { data, _ -> [GiphyImageInfo] in + Logger.debug("search request succeeded") + + guard let imageInfos = self.parseGiphyImages(responseData: data) else { + throw HTTPError.invalidResponse + } + + return imageInfos + } + .eraseToAnyPublisher() } - // MARK: Parse API Responses + // MARK: - Parse API Responses - private func parseGiphyImages(responseJson: Any?) -> [GiphyImageInfo]? { - guard let responseJson = responseJson else { + private static func parseGiphyImages(responseData: Data?) -> [GiphyImageInfo]? { + guard let responseData: Data = responseData else { Logger.error("Missing response.") return nil } - guard let responseDict = responseJson as? [String: Any] else { + guard let responseDict: [String: Any] = try? JSONSerialization + .jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? [String: Any] else { Logger.error("Invalid response.") return nil } @@ -389,7 +380,7 @@ extension GiphyError: LocalizedError { } // Giphy API results are often incomplete or malformed, so we need to be defensive. - private func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? { + private static func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? { guard let giphyId = imageDict["id"] as? String else { Logger.warn("Image dict missing id.") return nil @@ -424,12 +415,14 @@ extension GiphyError: LocalizedError { return nil } - return GiphyImageInfo(giphyId: giphyId, - renditions: renditions, - originalRendition: originalRendition) + return GiphyImageInfo( + giphyId: giphyId, + renditions: renditions, + originalRendition: originalRendition + ) } - private func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? { + private static func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? { for rendition in renditions where rendition.name == "original" { return rendition } @@ -439,8 +432,10 @@ extension GiphyError: LocalizedError { // Giphy API results are often incomplete or malformed, so we need to be defensive. // // We should discard renditions which are missing or have invalid properties. - private func parseGiphyRendition(renditionName: String, - renditionDict: [String: Any]) -> GiphyRendition? { + private static func parseGiphyRendition( + renditionName: String, + renditionDict: [String: Any] + ) -> GiphyRendition? { guard let width = parsePositiveUInt(dict: renditionDict, key: "width", typeName: "rendition") else { return nil } @@ -488,7 +483,7 @@ extension GiphyError: LocalizedError { ) } - private func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? { + private static func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? { guard let value = dict[key] else { return nil } @@ -505,7 +500,7 @@ extension GiphyError: LocalizedError { return parsedValue } - private func parseLenientUInt(dict: [String: Any], key: String) -> UInt { + private static func parseLenientUInt(dict: [String: Any], key: String) -> UInt { let defaultValue = UInt(0) guard let value = dict[key] else { diff --git a/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift b/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift index 1f509322d..3dd50b0c0 100644 --- a/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift +++ b/Session/Media Viewing & Editing/GIFs/GiphyDownloader.swift @@ -1,15 +1,11 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation import SignalUtilitiesKit -@objc public class GiphyDownloader: ProxiedContentDownloader { // MARK: - Properties - @objc public static let giphyDownloader = GiphyDownloader(downloadFolderName: "GIFs") } diff --git a/Session/Media Viewing & Editing/ImagePickerController.swift b/Session/Media Viewing & Editing/ImagePickerController.swift index 5c0666ec4..e7837f15a 100644 --- a/Session/Media Viewing & Editing/ImagePickerController.swift +++ b/Session/Media Viewing & Editing/ImagePickerController.swift @@ -1,19 +1,18 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import Photos -import PromiseKit import SessionUIKit import SignalUtilitiesKit +import SignalCoreKit protocol ImagePickerGridControllerDelegate: AnyObject { func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController) func imagePickerDidCancel(_ imagePicker: ImagePickerGridController) func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool - func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise) + func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher) func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset) var isInBatchSelectMode: Bool { get } @@ -180,8 +179,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat return } - let attachmentPromise: Promise = photoCollectionContents.outgoingAttachment(for: asset) - delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise) + delegate.imagePicker( + self, + didSelectAsset: asset, + attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset) + ) collectionView.selectItem(at: indexPath, animated: true, scrollPosition: []) case .deselect: delegate.imagePicker(self, didDeselectAsset: asset) @@ -201,8 +203,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat let scale = UIScreen.main.scale let cellSize = collectionViewFlowLayout.itemSize photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale) - - reloadDataAndRestoreSelection() + if !hasEverAppeared { scrollToBottom(animated: false) } @@ -289,30 +290,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat } } - private func reloadDataAndRestoreSelection() { - guard let collectionView = collectionView else { - owsFailDebug("Missing collectionView.") - return - } - - guard let delegate = delegate else { - owsFailDebug("delegate was unexpectedly nil") - return - } - - collectionView.reloadData() - collectionView.layoutIfNeeded() - - let count = photoCollectionContents.assetCount - for index in 0.. = photoCollectionContents.outgoingAttachment(for: asset) - delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise) + delegate.imagePicker( + self, + didSelectAsset: asset, + attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset) + ) if !delegate.isInBatchSelectMode { // Don't show "selected" badge unless we're in batch mode @@ -524,7 +506,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath) let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) cell.configure(item: assetItem) - + cell.isAccessibilityElement = true + cell.accessibilityIdentifier = "\(assetItem.asset.modificationDate.map { "\($0)" } ?? "Unknown Date")" cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset) return cell diff --git a/Session/Media Viewing & Editing/MediaDetailViewController.swift b/Session/Media Viewing & Editing/MediaDetailViewController.swift index cb6cdec1c..1a7abeebe 100644 --- a/Session/Media Viewing & Editing/MediaDetailViewController.swift +++ b/Session/Media Viewing & Editing/MediaDetailViewController.swift @@ -5,6 +5,7 @@ import YYImage import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit +import SignalCoreKit public enum MediaGalleryOption { case sliderEnabled diff --git a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift index 7c7e39151..10eff35e3 100644 --- a/Session/Media Viewing & Editing/MediaGalleryViewModel.swift +++ b/Session/Media Viewing & Editing/MediaGalleryViewModel.swift @@ -48,8 +48,14 @@ public class MediaGalleryViewModel { 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], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges { - onGalleryChange?(unobservedGalleryDataChanges.0, unobservedGalleryDataChanges.1) + if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges { + let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onGalleryChange + + switch Thread.isMainThread { + case true: performChange?(changes.0, changes.1) + case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) } + } + self.unobservedGalleryDataChanges = nil } } @@ -98,7 +104,10 @@ public class MediaGalleryViewModel { currentDataRetriever: { self?.galleryData }, onDataChange: self?.onGalleryChange, onUnobservedDataChange: { updatedData, changeset in - self?.unobservedGalleryDataChanges = (updatedData, changeset) + self?.unobservedGalleryDataChanges = (changeset.isEmpty ? + nil : + (updatedData, changeset) + ) } ) } @@ -357,7 +366,7 @@ public class MediaGalleryViewModel { /// 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 typealias AlbumObservation = ValueObservation>>> public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil) private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation { @@ -380,6 +389,7 @@ public class MediaGalleryViewModel { .fetchAll(db) } .removeDuplicates() + .handleEvents(didFail: { SNLog("[MediaGalleryViewModel] Observation failed with error: \($0)") }) } @discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] { @@ -623,27 +633,3 @@ public class MediaGalleryViewModel { ) } } - -// 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: UINavigationController) { - fromNavController.pushViewController( - MediaGalleryViewModel.createAllMediaViewController( - threadId: threadId, - threadVariant: { - if isClosedGroup { return .closedGroup } - if isOpenGroup { return .openGroup } - - return .contact - }(), - focusedAttachmentId: nil - ), - animated: true - ) - } -} diff --git a/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift b/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift index b1c5483e1..a41368931 100644 --- a/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift +++ b/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift @@ -172,6 +172,7 @@ extension MediaInfoVC { } // MARK: - Interaction + public func update(attachment: Attachment?) { guard let attachment: Attachment = attachment else { return } @@ -179,7 +180,7 @@ extension MediaInfoVC { fileIdLabel.text = attachment.serverId fileTypeLabel.text = attachment.contentType - fileSizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount) + fileSizeLabel.text = Format.fileSize(attachment.byteCount) resolutionLabel.text = { guard let width = attachment.width, let height = attachment.height else { return "N/A" } return "\(width)×\(height)" diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 7da69bfb9..59cf1a58f 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -2,10 +2,10 @@ import UIKit import GRDB -import PromiseKit import SessionUIKit import SessionMessagingKit import SignalUtilitiesKit +import SignalCoreKit class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, InteractivelyDismissableViewController { class DynamicallySizedView: UIView { @@ -15,7 +15,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss? public let viewModel: MediaGalleryViewModel - private var dataChangeObservable: DatabaseCancellable? + private var dataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } private var initialPage: MediaDetailViewController private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:] @@ -40,7 +42,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou ) // Swap out the database observer - dataChangeObservable?.cancel() + stopObservingChanges() viewModel.replaceAlbumObservation(toObservationFor: item.interactionId) startObservingChanges() @@ -238,8 +240,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() resignFirstResponder() } @@ -252,8 +253,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -388,17 +388,23 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou // MARK: - Updating private func startObservingChanges() { + guard dataChangeObservable == nil else { return } + // 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 + // The default scheduler emits changes on the main thread self?.handleUpdates(albumData) } ) } + private func stopObservingChanges() { + dataChangeObservable = nil + } + 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 { @@ -533,11 +539,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou self.viewModel.threadVariant == .contact else { return } + let threadId: String = self.viewModel.threadId + let threadVariant: SessionThread.Variant = self.viewModel.threadVariant + 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( @@ -547,7 +552,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs()) ), interactionId: nil, // Show no interaction for the current user - in: thread + threadId: threadId, + threadVariant: threadVariant ) } } @@ -710,7 +716,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } // Swap out the database observer - dataChangeObservable?.cancel() + stopObservingChanges() viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter) startObservingChanges() @@ -755,7 +761,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } // Swap out the database observer - dataChangeObservable?.cancel() + stopObservingChanges() viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore) startObservingChanges() @@ -925,24 +931,19 @@ extension MediaGalleryViewModel.Item: GalleryRailItem { let imageView: UIImageView = UIImageView() imageView.contentMode = .scaleAspectFill - getRailImage() - .map { [weak imageView] image in - guard let imageView = imageView else { return } - imageView.image = image + self.thumbnailImage { [weak imageView] image in + DispatchQueue.main.async { + imageView?.image = image } - .retainUntilComplete() + } return imageView } - - public func getRailImage() -> Guarantee { - return Guarantee { fulfill in - self.thumbnailImage(async: { image in fulfill(image) }) - } - } public func isEqual(to other: GalleryRailItem?) -> Bool { - guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else { return false } + guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else { + return false + } return (self == otherItem) } diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index db7915b95..5d24475b9 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -6,6 +6,7 @@ import GRDB import DifferenceKit import SessionUIKit import SignalUtilitiesKit +import SignalCoreKit public class MediaTileViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { @@ -245,7 +246,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } private func autoLoadNextPageIfNeeded() { - guard !self.isAutoLoadingNextPage else { return } + guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return } self.isAutoLoadingNextPage = true @@ -306,12 +307,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour // 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) self.updateSelectButton(updatedData: updatedGalleryData, inBatchSelectMode: isInBatchSelectMode) UIView.performWithoutAnimation { self.collectionView.reloadData() + self.hasLoadedInitialData = true self.performInitialScrollIfNeeded() } return diff --git a/Session/Media Viewing & Editing/OWSImagePickerController.swift b/Session/Media Viewing & Editing/OWSImagePickerController.swift index 904a239df..d88e3a60a 100644 --- a/Session/Media Viewing & Editing/OWSImagePickerController.swift +++ b/Session/Media Viewing & Editing/OWSImagePickerController.swift @@ -3,6 +3,7 @@ // import Foundation +import SignalUtilitiesKit @objc class OWSImagePickerController: UIImagePickerController { diff --git a/Session/Media Viewing & Editing/PhotoCapture.swift b/Session/Media Viewing & Editing/PhotoCapture.swift index 50720f6f6..6f4687e85 100644 --- a/Session/Media Viewing & Editing/PhotoCapture.swift +++ b/Session/Media Viewing & Editing/PhotoCapture.swift @@ -1,12 +1,12 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import AVFoundation -import PromiseKit import CoreServices import SessionMessagingKit +import SessionUtilitiesKit +import SignalCoreKit protocol PhotoCaptureDelegate: AnyObject { func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment) @@ -83,77 +83,93 @@ class PhotoCapture: NSObject { Environment.shared?.audioSession.endAudioActivity(recordingAudioActivity) } - func startCapture() -> Promise { - return sessionQueue.async(.promise) { [weak self] in - guard let self = self else { return } + func startCapture() -> AnyPublisher { + return Just(()) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes + .setFailureType(to: Error.self) + .tryMap { [weak self] _ -> Void in + self?.session.beginConfiguration() + defer { self?.session.commitConfiguration() } - self.session.beginConfiguration() - defer { self.session.commitConfiguration() } + try self?.updateCurrentInput(position: .back) - try self.updateCurrentInput(position: .back) - - guard let photoOutput = self.captureOutput.photoOutput else { - throw PhotoCaptureError.initializationFailed - } - - guard self.session.canAddOutput(photoOutput) else { - throw PhotoCaptureError.initializationFailed - } - - if let connection = photoOutput.connection(with: .video) { - if connection.isVideoStabilizationSupported { - connection.preferredVideoStabilizationMode = .auto + guard + let photoOutput = self?.captureOutput.photoOutput, + self?.session.canAddOutput(photoOutput) == true + else { + throw PhotoCaptureError.initializationFailed } - } - self.session.addOutput(photoOutput) - - let movieOutput = self.captureOutput.movieOutput - - if self.session.canAddOutput(movieOutput) { - self.session.addOutput(movieOutput) - self.session.sessionPreset = .high - if let connection = movieOutput.connection(with: .video) { + if let connection = photoOutput.connection(with: .video) { if connection.isVideoStabilizationSupported { connection.preferredVideoStabilizationMode = .auto } } + + self?.session.addOutput(photoOutput) + + if + let movieOutput = self?.captureOutput.movieOutput, + self?.session.canAddOutput(movieOutput) == true + { + self?.session.addOutput(movieOutput) + self?.session.sessionPreset = .high + + if let connection = movieOutput.connection(with: .video) { + if connection.isVideoStabilizationSupported { + connection.preferredVideoStabilizationMode = .auto + } + } + } + + return () } - }.done(on: sessionQueue) { - self.session.startRunning() - } + .handleEvents( + receiveCompletion: { [weak self] result in + switch result { + case .failure: break + case .finished: self?.session.startRunning() + } + } + ) + .eraseToAnyPublisher() } - func stopCapture() -> Guarantee { - return sessionQueue.async(.promise) { - self.session.stopRunning() - } + func stopCapture() -> AnyPublisher { + return Just(()) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes + .handleEvents( + receiveOutput: { [weak self] in self?.session.stopRunning() } + ) + .eraseToAnyPublisher() } func assertIsOnSessionQueue() { assertOnQueue(sessionQueue) } - func switchCamera() -> Promise { + func switchCamera() -> AnyPublisher { AssertIsOnMainThread() - let newPosition: AVCaptureDevice.Position - switch desiredPosition { - case .front: - newPosition = .back - case .back: - newPosition = .front - case .unspecified: - newPosition = .front - } - desiredPosition = newPosition - return sessionQueue.async(.promise) { [weak self] in - guard let self = self else { return } - - self.session.beginConfiguration() - defer { self.session.commitConfiguration() } - try self.updateCurrentInput(position: newPosition) - } + desiredPosition = { + switch desiredPosition { + case .front: return .back + case .back: return .front + case .unspecified: return .front + } + }() + + return Just(()) + .setFailureType(to: Error.self) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes + .tryMap { [weak self, newPosition = self.desiredPosition] _ -> Void in + self?.session.beginConfiguration() + defer { self?.session.commitConfiguration() } + + try self?.updateCurrentInput(position: newPosition) + return () + } + .eraseToAnyPublisher() } // This method should be called on the serial queue, @@ -179,20 +195,29 @@ class PhotoCapture: NSObject { resetFocusAndExposure() } - func switchFlashMode() -> Guarantee { - return sessionQueue.async(.promise) { - switch self.captureOutput.flashMode { - case .auto: - Logger.debug("new flashMode: on") - self.captureOutput.flashMode = .on - case .on: - Logger.debug("new flashMode: off") - self.captureOutput.flashMode = .off - case .off: - Logger.debug("new flashMode: auto") - self.captureOutput.flashMode = .auto - } - } + func switchFlashMode() -> AnyPublisher { + return Just(()) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes + .handleEvents( + receiveOutput: { [weak self] _ in + switch self?.captureOutput.flashMode { + case .auto: + Logger.debug("new flashMode: on") + self?.captureOutput.flashMode = .on + + case .on: + Logger.debug("new flashMode: off") + self?.captureOutput.flashMode = .off + + case .off: + Logger.debug("new flashMode: auto") + self?.captureOutput.flashMode = .auto + + default: break + } + } + ) + .eraseToAnyPublisher() } func focus(with focusMode: AVCaptureDevice.FocusMode, @@ -325,14 +350,23 @@ extension PhotoCapture: CaptureButtonDelegate { AssertIsOnMainThread() Logger.verbose("") - sessionQueue.async(.promise) { - try self.startAudioCapture() - self.captureOutput.beginVideo(delegate: self) - }.done { - self.delegate?.photoCaptureDidBeginVideo(self) - }.catch { error in - self.delegate?.photoCapture(self, processingDidError: error) - }.retainUntilComplete() + + Just(()) + .subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes + .sinkUntilComplete( + receiveCompletion: { [weak self] _ in + guard let strongSelf = self else { return } + + do { + try strongSelf.startAudioCapture() + strongSelf.captureOutput.beginVideo(delegate: strongSelf) + strongSelf.delegate?.photoCaptureDidBeginVideo(strongSelf) + } + catch { + strongSelf.delegate?.photoCapture(strongSelf, processingDidError: error) + } + } + ) } func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) { diff --git a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift index f690ed88a..91e43eb61 100644 --- a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift +++ b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift @@ -1,10 +1,11 @@ -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import AVFoundation -import PromiseKit import SessionUIKit import SignalUtilitiesKit +import SignalCoreKit protocol PhotoCaptureViewControllerDelegate: AnyObject { func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment) @@ -39,9 +40,15 @@ class PhotoCaptureViewController: OWSViewController { deinit { UIDevice.current.endGeneratingDeviceOrientationNotifications() if let photoCapture = photoCapture { - photoCapture.stopCapture().done { - Logger.debug("stopCapture completed") - }.retainUntilComplete() + photoCapture.stopCapture() + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .failure: break + case .finished: Logger.debug("stopCapture completed") + } + } + ) } } @@ -186,17 +193,29 @@ class PhotoCaptureViewController: OWSViewController { let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001 self.switchCameraControl.button.transform = self.switchCameraControl.button.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation) } - photoCapture.switchCamera().catch { error in - self.showFailureUI(error: error) - }.retainUntilComplete() + + photoCapture.switchCamera() + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure(let error): self?.showFailureUI(error: error) + } + } + ) } @objc func didTapFlashMode() { Logger.debug("") - photoCapture.switchFlashMode().done { - self.updateFlashModeControl() - }.retainUntilComplete() + photoCapture.switchFlashMode() + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { [weak self] _ in + self?.updateFlashModeControl() + } + ) } @objc @@ -287,13 +306,15 @@ class PhotoCaptureViewController: OWSViewController { previewView = CapturePreviewView(session: photoCapture.session) photoCapture.startCapture() - .done { [weak self] in - self?.showCaptureUI() - } - .catch { [weak self] error in - self?.showFailureUI(error: error) - } - .retainUntilComplete() + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { [weak self] result in + switch result { + case .finished: self?.showCaptureUI() + case .failure(let error): self?.showFailureUI(error: error) + } + } + ) } private func showCaptureUI() { @@ -580,7 +601,7 @@ class RecordingTimerView: UIView { private lazy var label: UILabel = { let label: UILabel = UILabel() - label.font = .ows_monospacedDigitFont(withSize: 20) + label.font = UIFont.monospacedDigitSystemFont(ofSize: 20, weight: .regular) label.themeTextColor = .textPrimary label.textAlignment = .center label.layer.shadowOffset = CGSize.zero diff --git a/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift b/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift index d7a064a55..fe4d35098 100644 --- a/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift +++ b/Session/Media Viewing & Editing/PhotoCollectionPickerViewModel.swift @@ -34,12 +34,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel Promise<(dataSource: DataSource, dataUTI: String)> { - return Promise { resolver in - - let options: PHImageRequestOptions = PHImageRequestOptions() - options.isNetworkAccessAllowed = true - - _ = imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in - - guard let imageData = imageData else { - resolver.reject(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil")) - return - } - - guard let dataUTI = dataUTI else { - resolver.reject(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil")) - return - } - - guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else { - resolver.reject(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil")) - return - } - - resolver.fulfill((dataSource: dataSource, dataUTI: dataUTI)) - } - } - } - - private func requestVideoDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> { - return Promise { resolver in - - let options: PHVideoRequestOptions = PHVideoRequestOptions() - options.isNetworkAccessAllowed = true - - _ = imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in - - guard let exportSession = exportSession else { - resolver.reject(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil")) - return - } - - exportSession.outputFileType = AVFileType.mp4 - exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() - - let exportPath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4") - let exportURL = URL(fileURLWithPath: exportPath) - exportSession.outputURL = exportURL - - Logger.debug("starting video export") - exportSession.exportAsynchronously { - Logger.debug("Completed video export") - - guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else { - resolver.reject(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL")) + private func requestImageDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: DataSource, dataUTI: String), Error> { + return Deferred { + Future { [weak self] resolver in + + let options: PHImageRequestOptions = PHImageRequestOptions() + options.isNetworkAccessAllowed = true + + _ = self?.imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in + + guard let imageData = imageData else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil"))) return } - - resolver.fulfill((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String)) + + guard let dataUTI = dataUTI else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil"))) + return + } + + guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil"))) + return + } + + resolver(Result.success((dataSource: dataSource, dataUTI: dataUTI))) } } } + .eraseToAnyPublisher() } - func outgoingAttachment(for asset: PHAsset) -> Promise { + private func requestVideoDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: DataSource, dataUTI: String), Error> { + return Deferred { + Future { [weak self] resolver in + + let options: PHVideoRequestOptions = PHVideoRequestOptions() + options.isNetworkAccessAllowed = true + + _ = self?.imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in + + guard let exportSession = exportSession else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil"))) + return + } + + exportSession.outputFileType = AVFileType.mp4 + exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() + + let exportPath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4") + let exportURL = URL(fileURLWithPath: exportPath) + exportSession.outputURL = exportURL + + Logger.debug("starting video export") + exportSession.exportAsynchronously { + Logger.debug("Completed video export") + + guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else { + resolver(Result.failure(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL"))) + return + } + + resolver(Result.success((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String))) + } + } + } + } + .eraseToAnyPublisher() + } + + func outgoingAttachment(for asset: PHAsset) -> AnyPublisher { switch asset.mediaType { - case .image: - return requestImageDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in - return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium) - } - case .video: - return requestVideoDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in - return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI) - } - default: - return Promise(error: PhotoLibraryError.unsupportedMediaType) + case .image: + return requestImageDataSource(for: asset) + .map { (dataSource: DataSource, dataUTI: String) in + SignalAttachment + .attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium) + } + .eraseToAnyPublisher() + + case .video: + return requestVideoDataSource(for: asset) + .map { (dataSource: DataSource, dataUTI: String) in + SignalAttachment + .attachment(dataSource: dataSource, dataUTI: dataUTI) + } + .eraseToAnyPublisher() + + default: + return Fail(error: PhotoLibraryError.unsupportedMediaType) + .eraseToAnyPublisher() } } } diff --git a/Session/Media Viewing & Editing/SendMediaNavigationController.swift b/Session/Media Viewing & Editing/SendMediaNavigationController.swift index 71e8f231b..63bf1fde4 100644 --- a/Session/Media Viewing & Editing/SendMediaNavigationController.swift +++ b/Session/Media Viewing & Editing/SendMediaNavigationController.swift @@ -1,9 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import Photos -import PromiseKit import SignalUtilitiesKit +import SignalCoreKit +import SessionUIKit class SendMediaNavigationController: UINavigationController { public override var preferredStatusBarStyle: UIStatusBarStyle { @@ -15,6 +17,7 @@ class SendMediaNavigationController: UINavigationController { static let bottomButtonsCenterOffset: CGFloat = -50 private let threadId: String + private var disposables: Set = Set() // MARK: - Initialization @@ -324,32 +327,40 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { func showApprovalAfterProcessingAnyMediaLibrarySelections() { let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues - let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { modal in - let attachmentPromises: [Promise] = mediaLibrarySelections.map { $0.promise } - - when(fulfilled: attachmentPromises) - .map { attachments in - Logger.debug("built all attachments") - modal.dismiss { - self.attachmentDraftCollection.selectedFromPicker(attachments: attachments) - self.pushApprovalViewController() + let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { [weak self] modal in + guard let strongSelf = self else { return } + + Publishers + .MergeMany(mediaLibrarySelections.map { $0.publisher }) + .collect() + .sink( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + Logger.error("failed to prepare attachments. error: \(error)") + modal.dismiss { [weak self] in + let modal: ConfirmationModal = ConfirmationModal( + targetView: self?.view, + info: ConfirmationModal.Info( + title: "IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS".localized(), + cancelTitle: "BUTTON_OK".localized(), + cancelStyle: .alert_text + ) + ) + self?.present(modal, animated: true) + } + } + }, + receiveValue: { attachments in + Logger.debug("built all attachments") + modal.dismiss { + self?.attachmentDraftCollection.selectedFromPicker(attachments: attachments) + self?.pushApprovalViewController() + } } - } - .catch { error in - Logger.error("failed to prepare attachments. error: \(error)") - modal.dismiss { [weak self] in - let modal: ConfirmationModal = ConfirmationModal( - targetView: self?.view, - info: ConfirmationModal.Info( - title: "IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS".localized(), - cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text - ) - ) - self?.present(modal, animated: true) - } - } - .retainUntilComplete() + ) + .store(in: &strongSelf.disposables) } ModalActivityIndicatorViewController.present( @@ -363,10 +374,13 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate { return mediaLibrarySelections.hasValue(forKey: asset) } - func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise) { + func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher) { guard !mediaLibrarySelections.hasValue(forKey: asset) else { return } - let libraryMedia = MediaLibrarySelection(asset: asset, signalAttachmentPromise: attachmentPromise) + let libraryMedia = MediaLibrarySelection( + asset: asset, + signalAttachmentPublisher: attachmentPublisher + ) mediaLibrarySelections.append(key: asset, value: libraryMedia) updateButtons(topViewController: imagePicker) } @@ -511,17 +525,17 @@ private final class AttachmentDraftCollection { private struct MediaLibrarySelection: Hashable, Equatable { let asset: PHAsset - let signalAttachmentPromise: Promise + let signalAttachmentPublisher: AnyPublisher var hashValue: Int { return asset.hashValue } - var promise: Promise { + var publisher: AnyPublisher { let asset = self.asset - return signalAttachmentPromise.map { signalAttachment in - return MediaLibraryAttachment(asset: asset, signalAttachment: signalAttachment) - } + return signalAttachmentPublisher + .map { MediaLibraryAttachment(asset: asset, signalAttachment: $0) } + .eraseToAnyPublisher() } static func ==(lhs: MediaLibrarySelection, rhs: MediaLibrarySelection) -> Bool { @@ -583,7 +597,10 @@ private class DoneButton: UIView { private lazy var badgeLabel: UILabel = { let result: UILabel = UILabel() - result.font = .ows_dynamicTypeSubheadline.ows_monospaced() + result.font = UIFont.monospacedDigitSystemFont( + ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize, + weight: .regular + ) result.themeTextColor = .black // Will render on the primary color so should always be black result.textAlignment = .center diff --git a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift index 8a3c8a8db..76e880278 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaDismissAnimationController.swift @@ -1,7 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import PromiseKit +import SessionUIKit class MediaDismissAnimationController: NSObject { private let mediaItem: Media @@ -47,6 +47,18 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning switch fromVC { case let contextProvider as MediaPresentationContextProvider: fromContextProvider = contextProvider + + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController, + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { + transitionContext.completeTransition(false) + return + } + + fromContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { @@ -65,6 +77,19 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning case let contextProvider as MediaPresentationContextProvider: toVC.view.layoutIfNeeded() toContextProvider = contextProvider + + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController, + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { + transitionContext.completeTransition(false) + return + } + + toVC.view.layoutIfNeeded() + toContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { diff --git a/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift b/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift index bd213e0b9..ad1cb6fcd 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaInteractiveDismiss.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SignalUtilitiesKit // MARK: - InteractivelyDismissableViewController diff --git a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift index 83efc9a24..7dd7a4f0b 100644 --- a/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift +++ b/Session/Media Viewing & Editing/Transitions/MediaZoomAnimationController.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit class MediaZoomAnimationController: NSObject { private let mediaItem: Media @@ -34,6 +35,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { switch fromVC { case let contextProvider as MediaPresentationContextProvider: fromContextProvider = contextProvider + + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController, + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { + transitionContext.completeTransition(false) + return + } + + fromContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { @@ -51,6 +64,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning { switch toVC { case let contextProvider as MediaPresentationContextProvider: toContextProvider = contextProvider + + case let topBannerController as TopBannerController: + guard + let firstChild: UIViewController = topBannerController.children.first, + let navController: UINavigationController = firstChild as? UINavigationController, + let contextProvider = navController.topViewController as? MediaPresentationContextProvider + else { + transitionContext.completeTransition(false) + return + } + + toContextProvider = contextProvider case let navController as UINavigationController: guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else { diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 84df254e4..eaee3557d 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -1,32 +1,35 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import Foundation -import GRDB -import PromiseKit -import WebRTC -import SessionUIKit import UIKit +import Combine +import UserNotifications +import GRDB +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit -import SessionUIKit -import UserNotifications -import UIKit import SignalUtilitiesKit +import SignalCoreKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { + private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 10 + var window: UIWindow? var backgroundSnapshotBlockerWindow: UIWindow? var appStartupWindow: UIWindow? + var initialLaunchFailed: Bool = false var hasInitialRootViewController: Bool = false + var startTime: CFTimeInterval = 0 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() + lazy var poller: CurrentUserPoller = CurrentUserPoller() // MARK: - Lifecycle func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + startTime = CACurrentMediaTime() + // These should be the first things we do (the startup process can fail without them) SetCurrentAppContext(MainAppContext()) verifyDBKeysAvailableBeforeBackgroundLaunch() @@ -44,9 +47,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD let mainWindow: UIWindow = TraitObservingWindow(frame: UIScreen.main.bounds) self.loadingViewController = LoadingViewController() - // Store a weak reference in the ThemeManager so it can properly apply themes as needed - ThemeManager.mainWindow = mainWindow - AppSetup.setupEnvironment( appSpecificBlock: { // Create AppEnvironment @@ -71,11 +71,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD }, migrationsCompletion: { [weak self] result, needsConfigSync in if case .failure(let error) = result { - self?.showFailedMigrationAlert(error: error) + DispatchQueue.main.async { + self?.initialLaunchFailed = true + self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .databaseError(error)) + } return } - self?.completePostMigrationSetup(needsConfigSync: needsConfigSync) + /// Store a weak reference in the ThemeManager so it can properly apply themes as needed + /// + /// **Note:** Need to do this after the db migrations because theme preferences are stored in the database and + /// we don't want to access it until after the migrations run + ThemeManager.mainWindow = mainWindow + self?.completePostMigrationSetup(calledFrom: .finishLaunching, needsConfigSync: needsConfigSync) } ) @@ -128,9 +136,58 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Resume database NotificationCenter.default.post(name: Database.resumeNotification, object: self) + + // Reset the 'startTime' (since it would be invalid from the last launch) + startTime = CACurrentMediaTime() + + // If we've already completed migrations at least once this launch then check + // to see if any "delayed" migrations now need to run + if Storage.shared.hasCompletedMigrations { + SNLog("Checking for pending migrations") + let initialLaunchFailed: Bool = self.initialLaunchFailed + + AppReadiness.invalidate() + + // If the user went to the background too quickly then the database can be suspended before + // properly starting up, in this case an alert will be shown but we can recover from it so + // dismiss any alerts that were shown + if initialLaunchFailed { + self.window?.rootViewController?.dismiss(animated: false) + } + + // Dispatch async so things can continue to be progressed if a migration does need to run + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + AppSetup.runPostSetupMigrations( + migrationProgressChanged: { progress, minEstimatedTotalTime in + self?.loadingViewController?.updateProgress( + progress: progress, + minEstimatedTotalTime: minEstimatedTotalTime + ) + }, + migrationsCompletion: { result, needsConfigSync in + if case .failure(let error) = result { + DispatchQueue.main.async { + self?.showFailedStartupAlert( + calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed), + error: .databaseError(error) + ) + } + return + } + + self?.completePostMigrationSetup( + calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed), + needsConfigSync: needsConfigSync + ) + } + ) + } + } } func applicationDidEnterBackground(_ application: UIApplication) { + if !hasInitialRootViewController { SNLog("Entered background before startup was completed") } + DDLog.flushLog() // NOTE: Fix an edge case where user taps on the callkit notification @@ -160,7 +217,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD UserDefaults.sharedLokiProject?[.isMainAppActive] = true - ensureRootViewController() + ensureRootViewController(calledFrom: .didBecomeActive) AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in self?.handleActivation() @@ -234,6 +291,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD BackgroundPoller.isValid = true AppReadiness.runNowOrWhenAppDidBecomeReady { + // If the 'AppReadiness' process takes too long then it's possible for the user to open + // the app after this closure is registered but before it's actually triggered - this can + // result in the `BackgroundPoller` incorrectly getting called in the foreground, this check + // is here to prevent that + guard CurrentAppContext().isInBackground() else { return } + BackgroundPoller.poll { result in guard BackgroundPoller.isValid else { return } @@ -252,101 +315,163 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - App Readiness - private func completePostMigrationSetup(needsConfigSync: Bool) { + private func completePostMigrationSetup(calledFrom lifecycleMethod: LifecycleMethod, needsConfigSync: Bool) { + SNLog("Migrations completed, performing setup and ensuring rootViewController") Configuration.performMainSetup() JobRunner.setExecutor(SyncPushTokensJob.self, for: .syncPushTokens) - /// Setup the UI - /// - /// **Note:** This **MUST** be run before calling: - /// - `AppReadiness.setAppIsReady()`: - /// 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 - /// - /// - `JobRunner.appDidFinishLaunching()`: - /// The jobs which run on launch (eg. DisappearingMessages job) can impact the interactions - /// which get fetched to display on the home screen, if the PagedDatabaseObserver hasn't - /// been setup yet then the home screen can show stale (ie. deleted) interactions incorrectly - self.ensureRootViewController(isPreAppReadyCall: true) - - // Trigger any launch-specific jobs and start the JobRunner - JobRunner.appDidFinishLaunching() - - // Note that this does much more than set a flag; - // it will also run all deferred blocks (including the JobRunner - // 'appDidBecomeActive' method) - AppReadiness.setAppIsReady() - - DeviceSleepManager.sharedInstance.removeBlock(blockObject: self) - AppVersion.sharedInstance().mainAppLaunchDidComplete() - Environment.shared?.audioSession.setup() - Environment.shared?.reachabilityManager.setup() - - 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 + // Setup the UI if needed, then trigger any post-UI setup actions + self.ensureRootViewController(calledFrom: lifecycleMethod) { [weak self] success in + // If we didn't successfully ensure the rootViewController then don't continue as + // the user is in an invalid state (and should have already been shown a modal) + guard success else { return } - if Identity.userExists(db) { - let appVersion: AppVersion = AppVersion.sharedInstance() + SNLog("RootViewController ready, readying remaining processes") + self?.initialLaunchFailed = false + + /// Trigger any launch-specific jobs and start the JobRunner with `JobRunner.appDidFinishLaunching()` some + /// of these jobs (eg. DisappearingMessages job) can impact the interactions which get fetched to display on the home + /// screen, if the PagedDatabaseObserver hasn't been setup yet then the home screen can show stale (ie. deleted) + /// interactions incorrectly + if lifecycleMethod == .finishLaunching { + JobRunner.appDidFinishLaunching() + } + + /// Flag that the app is ready via `AppReadiness.setAppIsReady()` + /// + /// If we are launching the app from a push notification we need to ensure we wait until after the `HomeVC` is setup + /// otherwise it won't open the related thread + /// + /// **Note:** This this does much more than set a flag - it will also run all deferred blocks (including the JobRunner + /// `appDidBecomeActive` method hence why it **must** also come after calling + /// `JobRunner.appDidFinishLaunching()`) + AppReadiness.setAppIsReady() + + /// Remove the sleep blocking once the startup is done (needs to run on the main thread and sleeping while + /// doing the startup could suspend the database causing errors/crashes + DeviceSleepManager.sharedInstance.removeBlock(blockObject: self) + + /// App launch hasn't really completed until the main screen is loaded so wait until then to register it + AppVersion.sharedInstance().mainAppLaunchDidComplete() + + /// App won't be ready for extensions and no need to enqueue a config sync unless we successfully completed startup + Storage.shared.writeAsync { db in + // Increment the launch count (guaranteed to change which results in the write actually + // doing something and outputting and error if the DB is suspended) + db[.activeCounter] = ((db[.activeCounter] ?? 0) + 1) - // 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() + // 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.userCompletedRequiredOnboarding(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 + ) + { + ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db)) + } } } + + // Add a log to track the proper startup time of the app so we know whether we need to + // improve it in the future from user logs + let endTime: CFTimeInterval = CACurrentMediaTime() + SNLog("\(lifecycleMethod.timingName) completed in \((self?.startTime).map { ceil((endTime - $0) * 1000) } ?? -1)ms") } + + // May as well run these on the background thread + Environment.shared?.audioSession.setup() + Environment.shared?.reachabilityManager.setup() } - private func showFailedMigrationAlert(error: Error?) { - let alert = UIAlertController( + private func showFailedStartupAlert( + calledFrom lifecycleMethod: LifecycleMethod, + error: StartupError, + animated: Bool = true, + presentationCompletion: (() -> ())? = nil + ) { + /// This **must** be a standard `UIAlertController` instead of a `ConfirmationModal` because we may not + /// have access to the database when displaying this so can't extract theme information for styling purposes + let alert: UIAlertController = UIAlertController( title: "Session", - message: "DATABASE_MIGRATION_FAILED".localized(), + message: error.message, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in HelpViewModel.shareLogs(viewControllerToDismiss: alert) { [weak self] in - self?.showFailedMigrationAlert(error: error) + // Don't bother showing the "Failed Startup" modal again if we happen to now + // have an initial view controller (this most likely means that the startup + // completed while the user was sharing logs so we can just let the user use + // the app) + guard self?.hasInitialRootViewController == false else { return } + + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, 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] result, needsConfigSync in - if case .failure(let error) = result { - self?.showFailedMigrationAlert(error: error) - return - } - - self?.completePostMigrationSetup(needsConfigSync: needsConfigSync) - } - ) - }) - alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in + switch error { + // Don't offer the 'Restore' option if it was a 'startupFailed' error as a restore is unlikely to + // resolve it (most likely the database is locked or the key was somehow lost - safer to get them + // to restart and manually reinstall/restore) + case .databaseError(StorageError.startupFailed): break + + // Offer the 'Restore' option if it was a migration error + case .databaseError: + alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in + if SUKLegacy.hasLegacyDatabaseFile { + // 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) + } + } + else { + // If we don't have a legacy database then reset the current database for a clean migration + Storage.resetForCleanMigration() + } + + // Hide the top banner if there was one + TopBannerController.hide() + + // 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] result, needsConfigSync in + switch result { + case .failure: + DispatchQueue.main.async { + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore) + } + + case .success: + self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync) + } + } + ) + }) + + default: break + } + + alert.addAction(UIAlertAction(title: "APP_STARTUP_EXIT".localized(), style: .default) { _ in DDLog.flushLog() exit(0) }) - self.window?.rootViewController?.present(alert, animated: true, completion: nil) + SNLog("Showing startup alert due to error: \(error.name)") + self.window?.rootViewController?.present(alert, animated: animated, completion: presentationCompletion) } /// The user must unlock the device once after reboot before the database encryption key can be accessed. @@ -387,7 +512,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } private func handleActivation() { - guard Identity.userExists() else { return } + /// There is a _fun_ behaviour here where if the user launches the app, sends it to the background at the right time and then + /// opens it again the `AppReadiness` closures can be triggered before `applicationDidBecomeActive` has been + /// called again - this can result in odd behaviours so hold off on running this logic until it's properly called again + guard + Identity.userExists() && + UserDefaults.sharedLokiProject?[.isMainAppActive] == true + else { return } enableBackgroundRefreshIfNecessary() JobRunner.appDidBecomeActive() @@ -400,25 +531,110 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } - private func ensureRootViewController(isPreAppReadyCall: Bool = false) { - guard (AppReadiness.isAppReady() || isPreAppReadyCall) && Storage.shared.isValid && !hasInitialRootViewController else { - return + private func ensureRootViewController( + calledFrom lifecycleMethod: LifecycleMethod, + onComplete: @escaping ((Bool) -> ()) = { _ in } + ) { + let hasInitialRootViewController: Bool = self.hasInitialRootViewController + + // Always call the completion block and indicate whether we successfully created the UI + guard + Storage.shared.isValid && + ( + AppReadiness.isAppReady() || + lifecycleMethod == .finishLaunching || + lifecycleMethod == .enterForeground(initialLaunchFailed: true) + ) && + !hasInitialRootViewController + else { return DispatchQueue.main.async { onComplete(hasInitialRootViewController) } } + + /// Start a timeout for the creation of the rootViewController setup process (if it takes too long then we want to give the user + /// the option to export their logs) + let populateHomeScreenTimer: Timer = Timer.scheduledTimerOnMainThread( + withTimeInterval: AppDelegate.maxRootViewControllerInitialQueryDuration, + repeats: false + ) { [weak self] timer in + timer.invalidate() + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout) } - self.hasInitialRootViewController = true - self.window?.rootViewController = StyledNavigationController( - rootViewController: (Identity.userExists() ? - HomeVC() : - LandingVC() + // All logic which needs to run after the 'rootViewController' is created + let rootViewControllerSetupComplete: (UIViewController) -> () = { [weak self] rootViewController in + let presentedViewController: UIViewController? = self?.window?.rootViewController?.presentedViewController + let targetRootViewController: UIViewController = TopBannerController( + child: StyledNavigationController(rootViewController: rootViewController), + cachedWarning: UserDefaults.sharedLokiProject?[.topBannerWarningToShow] + .map { rawValue in TopBannerController.Warning(rawValue: rawValue) } ) - ) - UIViewController.attemptRotationToDeviceOrientation() + + /// Insert the `targetRootViewController` below the current view and trigger a layout without animation before properly + /// swapping the `rootViewController` over so we can avoid any weird initial layout behaviours + UIView.performWithoutAnimation { + self?.window?.rootViewController = targetRootViewController + } + + self?.hasInitialRootViewController = true + 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 = rootViewController as? HomeVC { + SessionApp.homeViewController.mutate { $0 = homeViewController } + } + + /// If we were previously presenting a viewController but are no longer preseting it then present it again + /// + /// **Note:** Looks like the OS will throw an exception if we try to present a screen which is already (or + /// was previously?) presented, even if it's not attached to the screen it seems... + switch presentedViewController { + case is UIAlertController, is ConfirmationModal: + /// If the viewController we were presenting happened to be the "failed startup" modal then we can dismiss it + /// automatically (while this seems redundant it's less jarring for the user than just instantly having it disappear) + self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout, animated: false) { + self?.window?.rootViewController?.dismiss(animated: true) + } + + case is UIActivityViewController: HelpViewModel.shareLogs(animated: false) + default: break + } + + // Setup is completed so run any post-setup tasks + onComplete(true) + } - /// **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 } + // Navigate to the approriate screen depending on the onboarding state + switch Onboarding.State.current { + case .newUser: + DispatchQueue.main.async { + let viewController: LandingVC = LandingVC() + populateHomeScreenTimer.invalidate() + rootViewControllerSetupComplete(viewController) + } + + case .missingName: + DispatchQueue.main.async { + let viewController: DisplayNameVC = DisplayNameVC(flow: .register) + populateHomeScreenTimer.invalidate() + rootViewControllerSetupComplete(viewController) + } + + case .completed: + DispatchQueue.main.async { + let viewController: HomeVC = HomeVC() + + /// We want to start observing the changes for the 'HomeVC' and want to wait until we actually get data back before we + /// continue as we don't want to show a blank home screen + DispatchQueue.global(qos: .userInitiated).async { + viewController.startObservingChanges() { + populateHomeScreenTimer.invalidate() + + DispatchQueue.main.async { + rootViewControllerSetupComplete(viewController) + } + } + } + } } } @@ -491,7 +707,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { AppReadiness.runNowOrWhenAppDidBecomeReady { - guard Identity.userExists() else { return } + guard Identity.userCompletedRequiredOnboarding() else { return } SessionApp.homeViewController.wrappedValue?.createNewConversation() completionHandler(true) @@ -567,17 +783,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) { guard Identity.userExists() else { return } - poller.startIfNeeded() - - guard shouldStartGroupPollers else { return } - - ClosedGroupPoller.shared.start() - OpenGroupManager.shared.startPolling() + /// There is a fun issue where if you launch without any valid paths then the pollers are guaranteed to fail their first poll due to + /// trying and failing to build paths without having the `SnodeAPI.snodePool` populated, by waiting for the + /// `JobRunner.blockingQueue` to complete we can have more confidence that paths won't fail to build incorrectly + JobRunner.afterBlockingQueue { [weak self] in + self?.poller.start() + + guard shouldStartGroupPollers else { return } + + ClosedGroupPoller.shared.start() + OpenGroupManager.shared.startPolling() + } } public func stopPollers(shouldStopUserPoller: Bool = true) { if shouldStopUserPoller { - poller.stop() + poller.stopAllPollers() } ClosedGroupPoller.shared.stopAllPollers() @@ -655,20 +876,81 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // MARK: - Config Sync func syncConfigurationIfNeeded() { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard !SessionUtil.userConfigsEnabled else { return } + let lastSync: Date = (UserDefaults.standard[.lastConfigurationSync] ?? .distantPast) 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.standard[.hasSyncedInitialConfiguration] { - UserDefaults.standard[.lastConfigurationSync] = Date() + .writeAsync( + updates: { db in + ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db)) + }, + completion: { _, result in + switch result { + case .failure: break + case .success: + // 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: - LifecycleMethod + +private enum LifecycleMethod: Equatable { + case finishLaunching + case enterForeground(initialLaunchFailed: Bool) + case didBecomeActive + + var timingName: String { + switch self { + case .finishLaunching: return "Launch" + case .enterForeground: return "EnterForeground" + case .didBecomeActive: return "BecomeActive" + } + } + + static func == (lhs: LifecycleMethod, rhs: LifecycleMethod) -> Bool { + switch (lhs, rhs) { + case (.finishLaunching, .finishLaunching): return true + case (.enterForeground(let lhsFailed), .enterForeground(let rhsFailed)): return (lhsFailed == rhsFailed) + case (.didBecomeActive, .didBecomeActive): return true + default: return false + } + } +} + +// MARK: - StartupError + +private enum StartupError: Error { + case databaseError(Error) + case failedToRestore + case startupTimeout + + var name: String { + switch self { + case .databaseError(StorageError.startupFailed): return "Database startup failed" + case .failedToRestore: return "Failed to restore" + case .databaseError: return "Database error" + case .startupTimeout: return "Startup timeout" + } + } + + var message: String { + switch self { + case .databaseError(StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized() + case .failedToRestore: return "DATABASE_RESTORE_FAILED".localized() + case .databaseError: return "DATABASE_MIGRATION_FAILED".localized() + case .startupTimeout: return "APP_STARTUP_TIMEOUT".localized() + } } } diff --git a/Session/Meta/AppEnvironment.swift b/Session/Meta/AppEnvironment.swift index 6c779d34f..afcb1e868 100644 --- a/Session/Meta/AppEnvironment.swift +++ b/Session/Meta/AppEnvironment.swift @@ -3,6 +3,7 @@ import Foundation import SessionUtilitiesKit import SignalUtilitiesKit +import SignalCoreKit public class AppEnvironment { diff --git a/Session/Meta/MainAppContext.h b/Session/Meta/MainAppContext.h index a5c0ac97e..6fab6a1ad 100644 --- a/Session/Meta/MainAppContext.h +++ b/Session/Meta/MainAppContext.h @@ -10,6 +10,8 @@ extern NSString *const ReportedApplicationStateDidChangeNotification; @interface MainAppContext : NSObject +- (instancetype)init; + @end NS_ASSUME_NONNULL_END diff --git a/Session/Meta/MainAppContext.m b/Session/Meta/MainAppContext.m index b7dfea6f6..21daaa5f2 100644 --- a/Session/Meta/MainAppContext.m +++ b/Session/Meta/MainAppContext.m @@ -4,7 +4,7 @@ #import "MainAppContext.h" #import "Session-Swift.h" -#import +#import #import NS_ASSUME_NONNULL_BEGIN @@ -252,7 +252,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic { OWSAssertDebug(block); - DispatchMainThreadSafe(^{ + [Threading dispatchMainThreadSafe:^{ if (self.isMainAppAndActive) { // App active blocks typically will be used to safely access the // shared data container, so use a background task to protect this @@ -266,7 +266,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic } [self.appActiveBlocks addObject:block]; - }); + }]; } - (void)runAppActiveBlocks diff --git a/Session/Meta/Session-Info.plist b/Session/Meta/Session-Info.plist index be0439a2f..87cdc72f3 100644 --- a/Session/Meta/Session-Info.plist +++ b/Session/Meta/Session-Info.plist @@ -2,13 +2,6 @@ - BuildDetails - - CarthageVersion - 0.36.0 - OSXVersion - 10.15.6 - CFBundleDevelopmentRegion en CFBundleDisplayName @@ -88,7 +81,7 @@ NSCameraUsageDescription Session needs camera access to take pictures and scan QR codes. NSFaceIDUsageDescription - Session's Screen Lock feature uses Face ID. + Session's Screen Lock feature uses Face ID. NSHumanReadableCopyright com.loki-project.loki-messenger NSMicrophoneUsageDescription diff --git a/Session/Meta/Session-Prefix.pch b/Session/Meta/Session-Prefix.pch deleted file mode 100644 index ce4735256..000000000 --- a/Session/Meta/Session-Prefix.pch +++ /dev/null @@ -1,14 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -#ifdef __OBJC__ - #import - #import - - #import - #import - #import -#endif diff --git a/Session/Meta/SessionApp.swift b/Session/Meta/SessionApp.swift index c17905cf2..7ffa9292c 100644 --- a/Session/Meta/SessionApp.swift +++ b/Session/Meta/SessionApp.swift @@ -3,64 +3,110 @@ import Foundation import SessionUtilitiesKit import SessionMessagingKit +import SignalCoreKit +import SessionUIKit public struct SessionApp { + // FIXME: Refactor this to be protocol based for unit testing (or even dynamic based on view hierarchy - do want to avoid needing to use the main thread to access them though) static let homeViewController: Atomic = Atomic(nil) + static let currentlyOpenConversationViewController: Atomic = Atomic(nil) + + static var versionInfo: String { + let buildNumber: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) + .map { " (\($0))" } + .defaulting(to: "") + let appVersion: String? = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) + .map { "App: \($0)\(buildNumber)" } + #if DEBUG + let commitInfo: String? = (Bundle.main.infoDictionary?["GitCommitHash"] as? String).map { "Commit: \($0)" } + #else + let commitInfo: String? = nil + #endif + + let versionInfo: [String] = [ + "iOS \(UIDevice.current.systemVersion)", + appVersion, + "libSession: \(SessionUtil.libSessionVersion)", + commitInfo + ].compactMap { $0 } + + return versionInfo.joined(separator: ", ") + } // 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( + public static func presentConversationCreatingIfNeeded( for threadId: String, - threadVariant: SessionThread.Variant, - isMessageRequest: Bool, - action: ConversationViewModel.Action, - focusInteractionId: Int64?, + variant: SessionThread.Variant, + action: ConversationViewModel.Action = .none, + dismissing presentingViewController: UIViewController?, animated: Bool ) { - guard Thread.isMainThread else { - DispatchQueue.main.async { - self.presentConversation( - for: threadId, - threadVariant: threadVariant, - isMessageRequest: isMessageRequest, - action: action, - focusInteractionId: focusInteractionId, - animated: animated - ) + let threadInfo: (threadExists: Bool, isMessageRequest: Bool)? = Storage.shared.read { db in + let isMessageRequest: Bool = { + switch variant { + case .contact: + return SessionThread + .isMessageRequest( + id: threadId, + variant: .contact, + currentUserPublicKey: getUserHexEncodedPublicKey(db), + shouldBeVisible: nil, + contactIsApproved: (try? Contact + .filter(id: threadId) + .select(.isApproved) + .asRequest(of: Bool.self) + .fetchOne(db)) + .defaulting(to: false), + includeNonVisible: true + ) + + default: return false + } + }() + + return (SessionThread.filter(id: threadId).isNotEmpty(db), isMessageRequest) + } + + // Store the post-creation logic in a closure to avoid duplication + let afterThreadCreated: () -> () = { + presentingViewController?.dismiss(animated: true, completion: nil) + + homeViewController.wrappedValue?.show( + threadId, + variant: variant, + isMessageRequest: (threadInfo?.isMessageRequest == true), + with: action, + focusedInteractionInfo: nil, + animated: animated + ) + } + + /// The thread should generally exist at the time of calling this method, but on the off change it doesn't then we need to `fetchOrCreate` it and + /// should do it on a background thread just in case something is keeping the DBWrite thread busy as in the past this could cause the app to hang + guard threadInfo?.threadExists == true else { + DispatchQueue.global(qos: .userInitiated).async { + Storage.shared.write { db in + try SessionThread.fetchOrCreate(db, id: threadId, variant: variant, shouldBeVisible: nil) + } + + // Send back to main thread for UI transitions + DispatchQueue.main.async { + afterThreadCreated() + } } return } - homeViewController.wrappedValue?.show( - threadId, - variant: threadVariant, - isMessageRequest: isMessageRequest, - with: action, - focusedInteractionId: focusInteractionId, - animated: animated - ) + // Send to main thread if needed + guard Thread.isMainThread else { + DispatchQueue.main.async { + afterThreadCreated() + } + return + } + + afterThreadCreated() } // MARK: - Functions @@ -69,7 +115,8 @@ public struct SessionApp { // This _should_ be wiped out below. Logger.error("") DDLog.flushLog() - + + SessionUtil.clearMemoryState() Storage.resetAllStorage() ProfileManager.resetProfileStorage() Attachment.resetAttachmentStorage() diff --git a/Session/Meta/Settings.bundle/Acknowledgements.plist b/Session/Meta/Settings.bundle/Acknowledgements.plist deleted file mode 100644 index 128448f23..000000000 --- a/Session/Meta/Settings.bundle/Acknowledgements.plist +++ /dev/null @@ -1,1337 +0,0 @@ - - - - - StringsTable - Acknowledgements - PreferenceSpecifiers - - - Type - PSGroupSpecifier - FooterText - 25519 - - - Type - PSGroupSpecifier - FooterText - 255192 - - - Type - PSGroupSpecifier - FooterText - 255193 - - - Type - PSGroupSpecifier - FooterText - 255194 - - - Type - PSGroupSpecifier - FooterText - 255195 - - - Type - PSGroupSpecifier - FooterText - 255196 - - - Type - PSGroupSpecifier - FooterText - 255197 - - - Type - PSGroupSpecifier - FooterText - 255198 - - - Type - PSGroupSpecifier - FooterText - 255199 - - - Type - PSGroupSpecifier - FooterText - 2551910 - - - Type - PSGroupSpecifier - FooterText - 2551911 - - - Type - PSGroupSpecifier - FooterText - 2551912 - - - Type - PSGroupSpecifier - FooterText - 2551913 - - - Type - PSGroupSpecifier - FooterText - 2551914 - - - Type - PSGroupSpecifier - FooterText - 2551915 - - - Type - PSGroupSpecifier - FooterText - 2551916 - - - Type - PSGroupSpecifier - FooterText - 2551917 - - - Type - PSGroupSpecifier - FooterText - 2551918 - - - Type - PSGroupSpecifier - FooterText - 2551919 - - - Type - PSGroupSpecifier - FooterText - 2551920 - - - Type - PSGroupSpecifier - FooterText - 2551921 - - - Type - PSGroupSpecifier - FooterText - 2551922 - - - Type - PSGroupSpecifier - FooterText - 2551923 - - - Type - PSGroupSpecifier - FooterText - 2551924 - - - Type - PSGroupSpecifier - FooterText - 2551925 - - - Type - PSGroupSpecifier - FooterText - 2551926 - - - Type - PSGroupSpecifier - FooterText - 2551927 - - - Type - PSGroupSpecifier - FooterText - 2551928 - - - Type - PSGroupSpecifier - FooterText - 2551929 - - - Type - PSGroupSpecifier - FooterText - 2551930 - - - Type - PSGroupSpecifier - FooterText - 2551931 - - - Type - PSGroupSpecifier - FooterText - 2551932 - - - Type - PSGroupSpecifier - FooterText - 2551933 - - - Type - PSGroupSpecifier - FooterText - 2551934 - - - Type - PSGroupSpecifier - FooterText - 2551935 - - - Type - PSGroupSpecifier - FooterText - 2551936 - - - Type - PSGroupSpecifier - FooterText - 2551937 - - - Type - PSGroupSpecifier - FooterText - 2551938 - - - Type - PSGroupSpecifier - FooterText - 2551939 - - - Type - PSGroupSpecifier - FooterText - 2551940 - - - Type - PSGroupSpecifier - FooterText - 2551941 - - - Type - PSGroupSpecifier - FooterText - 2551942 - - - Type - PSGroupSpecifier - FooterText - 2551943 - - - Type - PSGroupSpecifier - FooterText - 2551944 - - - Type - PSGroupSpecifier - FooterText - 2551945 - - - Type - PSGroupSpecifier - FooterText - 2551946 - - - Type - PSGroupSpecifier - FooterText - 2551947 - - - Type - PSGroupSpecifier - FooterText - 2551948 - - - Type - PSGroupSpecifier - FooterText - 2551949 - - - Type - PSGroupSpecifier - FooterText - 2551950 - - - Type - PSGroupSpecifier - FooterText - 2551951 - - - Type - PSGroupSpecifier - FooterText - 2551952 - - - Type - PSGroupSpecifier - FooterText - 2551953 - - - Type - PSGroupSpecifier - FooterText - 2551954 - - - Type - PSGroupSpecifier - FooterText - 2551955 - - - Type - PSGroupSpecifier - FooterText - 2551956 - - - Type - PSGroupSpecifier - FooterText - 2551957 - - - Type - PSGroupSpecifier - FooterText - 2551958 - - - Type - PSGroupSpecifier - FooterText - 2551959 - - - Type - PSGroupSpecifier - FooterText - 2551960 - - - Type - PSGroupSpecifier - FooterText - AFNetworking - - - Type - PSGroupSpecifier - FooterText - AFNetworking2 - - - Type - PSGroupSpecifier - FooterText - AFNetworking3 - - - Type - PSGroupSpecifier - FooterText - AFNetworking4 - - - Type - PSGroupSpecifier - FooterText - AFNetworking5 - - - Type - PSGroupSpecifier - FooterText - APDropDownNavToolbar - - - Type - PSGroupSpecifier - FooterText - APDropDownNavToolbar2 - - - Type - PSGroupSpecifier - FooterText - APDropDownNavToolbar3 - - - Type - PSGroupSpecifier - FooterText - APDropDownNavToolbar4 - - - Type - PSGroupSpecifier - FooterText - APDropDownNavToolbar5 - - - Type - PSGroupSpecifier - FooterText - APDropDownNavToolbar6 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit - - - Type - PSGroupSpecifier - FooterText - AxolotlKit2 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit3 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit4 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit5 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit6 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit7 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit8 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit9 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit10 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit11 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit12 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit13 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit14 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit15 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit16 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit17 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit18 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit19 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit20 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit21 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit22 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit23 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit24 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit25 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit26 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit27 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit28 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit29 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit30 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit31 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit32 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit33 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit34 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit35 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit36 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit37 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit38 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit39 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit40 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit41 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit42 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit43 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit44 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit45 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit46 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit47 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit48 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit49 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit50 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit51 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit52 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit53 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit54 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit55 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit56 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit57 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit58 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit59 - - - Type - PSGroupSpecifier - FooterText - AxolotlKit60 - - - Type - PSGroupSpecifier - FooterText - CocoaLumberjack - - - Type - PSGroupSpecifier - FooterText - CocoaLumberjack2 - - - Type - PSGroupSpecifier - FooterText - CocoaLumberjack3 - - - Type - PSGroupSpecifier - FooterText - CocoaLumberjack4 - - - Type - PSGroupSpecifier - FooterText - CocoaLumberjack5 - - - Type - PSGroupSpecifier - FooterText - CocoaLumberjack6 - - - Type - PSGroupSpecifier - FooterText - CocoaLumberjack7 - - - Type - PSGroupSpecifier - FooterText - DJWActionSheet - - - Type - PSGroupSpecifier - FooterText - DJWActionSheet2 - - - Type - PSGroupSpecifier - FooterText - DJWActionSheet3 - - - Type - PSGroupSpecifier - FooterText - DJWActionSheet4 - - - Type - PSGroupSpecifier - FooterText - DJWActionSheet5 - - - Type - PSGroupSpecifier - FooterText - DJWActionSheet6 - - - Type - PSGroupSpecifier - FooterText - FFCircularProgressView - - - Type - PSGroupSpecifier - FooterText - FFCircularProgressView2 - - - Type - PSGroupSpecifier - FooterText - FFCircularProgressView3 - - - Type - PSGroupSpecifier - FooterText - FFCircularProgressView4 - - - Type - PSGroupSpecifier - FooterText - FFCircularProgressView5 - - - Type - PSGroupSpecifier - FooterText - JSQMessagesViewController - - - Type - PSGroupSpecifier - FooterText - JSQMessagesViewController2 - - - Type - PSGroupSpecifier - FooterText - JSQMessagesViewController3 - - - Type - PSGroupSpecifier - FooterText - JSQMessagesViewController4 - - - Type - PSGroupSpecifier - FooterText - JSQMessagesViewController5 - - - Type - PSGroupSpecifier - FooterText - JSQMessagesViewController6 - - - Type - PSGroupSpecifier - FooterText - Mantle - - - Type - PSGroupSpecifier - FooterText - Mantle2 - - - Type - PSGroupSpecifier - FooterText - Mantle3 - - - Type - PSGroupSpecifier - FooterText - Mantle4 - - - Type - PSGroupSpecifier - FooterText - Mantle5 - - - Type - PSGroupSpecifier - FooterText - Mantle6 - - - Type - PSGroupSpecifier - FooterText - Mantle7 - - - Type - PSGroupSpecifier - FooterText - Mantle8 - - - Type - PSGroupSpecifier - FooterText - Mantle9 - - - Type - PSGroupSpecifier - FooterText - Mantle10 - - - Type - PSGroupSpecifier - FooterText - OpenSSL - - - Type - PSGroupSpecifier - FooterText - OpenSSL2 - - - Type - PSGroupSpecifier - FooterText - OpenSSL3 - - - Type - PSGroupSpecifier - FooterText - OpenSSL4 - - - Type - PSGroupSpecifier - FooterText - OpenSSL5 - - - Type - PSGroupSpecifier - FooterText - OpenSSL6 - - - Type - PSGroupSpecifier - FooterText - OpenSSL7 - - - Type - PSGroupSpecifier - FooterText - SQLCipher - - - Type - PSGroupSpecifier - FooterText - SQLCipher2 - - - Type - PSGroupSpecifier - FooterText - SQLCipher3 - - - Type - PSGroupSpecifier - FooterText - SQLCipher4 - - - Type - PSGroupSpecifier - FooterText - SSKeychain - - - Type - PSGroupSpecifier - FooterText - SSKeychain2 - - - Type - PSGroupSpecifier - FooterText - SSKeychain3 - - - Type - PSGroupSpecifier - FooterText - SSKeychain4 - - - Type - PSGroupSpecifier - FooterText - SSKeychain5 - - - Type - PSGroupSpecifier - FooterText - SocketRocket - - - Type - PSGroupSpecifier - FooterText - SocketRocket2 - - - Type - PSGroupSpecifier - FooterText - SocketRocket3 - - - Type - PSGroupSpecifier - FooterText - SocketRocket4 - - - Type - PSGroupSpecifier - FooterText - SocketRocket5 - - - Type - PSGroupSpecifier - FooterText - YapDatabase - - - Type - PSGroupSpecifier - FooterText - YapDatabase2 - - - Type - PSGroupSpecifier - FooterText - YapDatabase3 - - - Type - PSGroupSpecifier - FooterText - YapDatabase4 - - - Type - PSGroupSpecifier - FooterText - YapDatabase5 - - - Type - PSGroupSpecifier - FooterText - YapDatabase6 - - - Type - PSGroupSpecifier - FooterText - YapDatabase7 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS2 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS3 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS4 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS5 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS6 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS7 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS8 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS9 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS10 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS11 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS12 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS13 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS14 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS15 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS16 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS17 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS18 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS19 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS20 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS21 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS22 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS23 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS24 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS25 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS26 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS27 - - - Type - PSGroupSpecifier - FooterText - libPhoneNumber-iOS28 - - - - diff --git a/Session/Meta/Settings.bundle/Root.plist b/Session/Meta/Settings.bundle/Root.plist index 300fb6413..b1b6fea5d 100644 --- a/Session/Meta/Settings.bundle/Root.plist +++ b/Session/Meta/Settings.bundle/Root.plist @@ -4,5 +4,58 @@ StringsTable Root + PreferenceSpecifiers + + + Type + PSGroupSpecifier + Title + Group + + + Type + PSTextFieldSpecifier + Title + Name + Key + name_preference + DefaultValue + + IsSecure + + KeyboardType + Alphabet + AutocapitalizationType + None + AutocorrectionType + No + + + Type + PSToggleSwitchSpecifier + Title + Enabled + Key + enabled_preference + DefaultValue + + + + Type + PSSliderSpecifier + Key + slider_preference + DefaultValue + 0.5 + MinimumValue + 0 + MaximumValue + 1 + MinimumValueImage + + MaximumValueImage + + + diff --git a/Session/Meta/Settings.bundle/en.lproj/Acknowledgements.strings b/Session/Meta/Settings.bundle/en.lproj/Acknowledgements.strings deleted file mode 100644 index 61fa63855..000000000 --- a/Session/Meta/Settings.bundle/en.lproj/Acknowledgements.strings +++ /dev/null @@ -1 +0,0 @@ -"25519" = "Curve25519-donna by Adam Langley https://github.com/agl/curve25519-donna"; "255192" = "GNU GENERAL PUBLIC LICENSE Version 2, June 1991"; "255193" = " Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed."; "255194" = " Preamble"; "255195" = " The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too."; "255196" = " When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things."; "255197" = " To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it."; "255198" = " For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights."; "255199" = " We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software."; "2551910" = " Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations."; "2551911" = " Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all."; "2551912" = " The precise terms and conditions for copying, distribution and modification follow."; "2551913" = " GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION"; "2551914" = " 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The 'Program', below, refers to any such program or work, and a 'work based on the Program' means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term 'modification'.) Each licensee is addressed as 'you'."; "2551915" = "Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does."; "2551916" = " 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program."; "2551917" = "You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee."; "2551918" = " 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:"; "2551919" = " a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change."; "2551920" = " b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License."; "2551921" = " c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)"; "2551922" = "These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it."; "2551923" = "Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program."; "2551924" = "In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License."; "2551925" = " 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:"; "2551926" = " a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,"; "2551927" = " b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,"; "2551928" = " c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)"; "2551929" = "The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable."; "2551930" = "If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code."; "2551931" = " 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance."; "2551932" = " 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it."; "2551933" = " 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License."; "2551934" = " 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program."; "2551935" = "If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances."; "2551936" = "It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice."; "2551937" = "This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License."; "2551938" = " 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License."; "2551939" = " 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns."; "2551940" = "Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and 'any later version', you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation."; "2551941" = " 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally."; "2551942" = " NO WARRANTY"; "2551943" = " 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM 'AS IS' WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION."; "2551944" = " 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES."; "2551945" = " END OF TERMS AND CONDITIONS"; "2551946" = " How to Apply These Terms to Your New Programs"; "2551947" = " If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms."; "2551948" = " To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the 'copyright' line and a pointer to where the full notice is found."; "2551949" = " {description} Copyright (C) {year} {fullname}"; "2551950" = " This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version."; "2551951" = " This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details."; "2551952" = " You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA."; "2551953" = "Also add information on how to contact you by electronic and paper mail."; "2551954" = "If the program is interactive, make it output a short notice like this when it starts in an interactive mode:"; "2551955" = " Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details."; "2551956" = "The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program."; "2551957" = "You should also get your employer (if you work as a programmer) or your school, if any, to sign a 'copyright disclaimer' for the program, if necessary. Here is a sample; alter the names:"; "2551958" = " Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker."; "2551959" = " {signature of Ty Coon}, 1 April 1989 Ty Coon, President of Vice"; "2551960" = "This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License."; "AFNetworking" = "AFNetworking https://github.com/AFNetworking/AFNetworking"; "AFNetworking2" = "Copyright (c) 2013-2015 AFNetworking (http://afnetworking.com/)"; "AFNetworking3" = "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:"; "AFNetworking4" = "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software."; "AFNetworking5" = "THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "; "APDropDownNavToolbar" = "APDropDownNavToolbar https://github.com/ankurp/APDropDownNavToolbar"; "APDropDownNavToolbar2" = "The MIT License (MIT)"; "APDropDownNavToolbar3" = "Copyright (c) 2013 Ankur Patel"; "APDropDownNavToolbar4" = "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:"; "APDropDownNavToolbar5" = "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software."; "APDropDownNavToolbar6" = "THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "; "AxolotlKit" = "AxolotlKit https://github.com/WhisperSystems/AxolotlKit"; "AxolotlKit2" = "GNU GENERAL PUBLIC LICENSE Version 2, June 1991"; "AxolotlKit3" = " Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed."; "AxolotlKit4" = " Preamble"; "AxolotlKit5" = " The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too."; "AxolotlKit6" = " When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things."; "AxolotlKit7" = " To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it."; "AxolotlKit8" = " For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights."; "AxolotlKit9" = " We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software."; "AxolotlKit10" = " Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations."; "AxolotlKit11" = " Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all."; "AxolotlKit12" = " The precise terms and conditions for copying, distribution and modification follow."; "AxolotlKit13" = " GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION"; "AxolotlKit14" = " 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The 'Program', below, refers to any such program or work, and a 'work based on the Program' means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term 'modification'.) Each licensee is addressed as 'you'."; "AxolotlKit15" = "Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does."; "AxolotlKit16" = " 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program."; "AxolotlKit17" = "You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee."; "AxolotlKit18" = " 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:"; "AxolotlKit19" = " a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change."; "AxolotlKit20" = " b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License."; "AxolotlKit21" = " c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)"; "AxolotlKit22" = "These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it."; "AxolotlKit23" = "Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program."; "AxolotlKit24" = "In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License."; "AxolotlKit25" = " 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:"; "AxolotlKit26" = " a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,"; "AxolotlKit27" = " b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,"; "AxolotlKit28" = " c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)"; "AxolotlKit29" = "The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable."; "AxolotlKit30" = "If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code."; "AxolotlKit31" = " 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance."; "AxolotlKit32" = " 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it."; "AxolotlKit33" = " 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License."; "AxolotlKit34" = " 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program."; "AxolotlKit35" = "If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances."; "AxolotlKit36" = "It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice."; "AxolotlKit37" = "This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License."; "AxolotlKit38" = " 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License."; "AxolotlKit39" = " 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns."; "AxolotlKit40" = "Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and 'any later version', you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation."; "AxolotlKit41" = " 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally."; "AxolotlKit42" = " NO WARRANTY"; "AxolotlKit43" = " 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM 'AS IS' WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION."; "AxolotlKit44" = " 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES."; "AxolotlKit45" = " END OF TERMS AND CONDITIONS"; "AxolotlKit46" = " How to Apply These Terms to Your New Programs"; "AxolotlKit47" = " If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms."; "AxolotlKit48" = " To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the 'copyright' line and a pointer to where the full notice is found."; "AxolotlKit49" = " {description} Copyright (C) {year} {fullname}"; "AxolotlKit50" = " This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version."; "AxolotlKit51" = " This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details."; "AxolotlKit52" = " You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA."; "AxolotlKit53" = "Also add information on how to contact you by electronic and paper mail."; "AxolotlKit54" = "If the program is interactive, make it output a short notice like this when it starts in an interactive mode:"; "AxolotlKit55" = " Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details."; "AxolotlKit56" = "The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program."; "AxolotlKit57" = "You should also get your employer (if you work as a programmer) or your school, if any, to sign a 'copyright disclaimer' for the program, if necessary. Here is a sample; alter the names:"; "AxolotlKit58" = " Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker."; "AxolotlKit59" = " {signature of Ty Coon}, 1 April 1989 Ty Coon, President of Vice"; "AxolotlKit60" = "This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License."; "CocoaLumberjack" = "CocoaLumberjack https://github.com/CocoaLumberjack/CocoaLumberjack"; "CocoaLumberjack2" = "Software License Agreement (BSD License)"; "CocoaLumberjack3" = "Copyright (c) 2010, Deusty, LLC All rights reserved."; "CocoaLumberjack4" = "Redistribution and use of this software in source and binary forms, with or without modification, are permitted provided that the following conditions are met:"; "CocoaLumberjack5" = "* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer."; "CocoaLumberjack6" = "* Neither the name of Deusty nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission of Deusty, LLC."; "CocoaLumberjack7" = "THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."; "DJWActionSheet" = "DJWActionSheet by DanWilliams64 https://github.com/danwilliams64/DJWActionSheet"; "DJWActionSheet2" = "The MIT License (MIT)"; "DJWActionSheet3" = "Copyright (c) 2014 Dan Williams"; "DJWActionSheet4" = "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:"; "DJWActionSheet5" = "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software."; "DJWActionSheet6" = "THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "; "FFCircularProgressView" = "FFCircularProgressView https://github.com/elbryan/FFCircularProgressView"; "FFCircularProgressView2" = "Copyright (c) 2013 Fabiano Francesconi"; "FFCircularProgressView3" = "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:"; "FFCircularProgressView4" = "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software."; "FFCircularProgressView5" = "THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."; "JSQMessagesViewController" = "JSQMessagesViewController https://github.com/jessesquires/JSQMessagesViewController"; "JSQMessagesViewController2" = "MIT License Copyright (c) 2014 Jesse Squires"; "JSQMessagesViewController3" = "http://www.hexedbits.com"; "JSQMessagesViewController4" = "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:"; "JSQMessagesViewController5" = "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software."; "JSQMessagesViewController6" = "THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "; "Mantle" = "Mantle https://github.com/Mantle/Mantle"; "Mantle2" = "**Copyright (c) 2012 - 2014, GitHub, Inc.** **All rights reserved.**"; "Mantle3" = "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:"; "Mantle4" = "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software."; "Mantle5" = "THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."; "Mantle6" = "---"; "Mantle7" = "**This project uses portions of code from the Proton framework.** **Proton is copyright (c) 2012, Bitswift, Inc.** **All rights reserved.**"; "Mantle8" = "Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:"; "Mantle9" = " * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Neither the name of the Bitswift, Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission."; "Mantle10" = "THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. "; "OpenSSL" = "OpenSSL https://www.openssl.org/"; "OpenSSL2" = " LICENSE ISSUES =============="; "OpenSSL3" = " The OpenSSL toolkit stays under a dual license, i.e. both the conditions of the OpenSSL License and the original SSLeay license apply to the toolkit. See below for the actual license texts. Actually both licenses are BSD-style Open Source licenses. In case of any license issues related to OpenSSL please contact openssl-core@openssl.org."; "OpenSSL4" = " OpenSSL License ---------------"; "OpenSSL5" = "/* ==================================================================== * Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in * the documentation and/or other materials provided with the * distribution. * * 3. All advertising materials mentioning features or use of this * software must display the following acknowledgment: * 'This product includes software developed by the OpenSSL Project * for use in the OpenSSL Toolkit. (http://www.openssl.org/)' * * 4. The names 'OpenSSL Toolkit' and 'OpenSSL Project' must not be used to * endorse or promote products derived from this software without * prior written permission. For written permission, please contact * openssl-core@openssl.org. * * 5. Products derived from this software may not be called 'OpenSSL' * nor may 'OpenSSL' appear in their names without prior written * permission of the OpenSSL Project. * * 6. Redistributions of any form whatsoever must retain the following * acknowledgment: * 'This product includes software developed by the OpenSSL Project * for use in the OpenSSL Toolkit (http://www.openssl.org/)' * * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED * OF THE POSSIBILITY OF SUCH DAMAGE. * ==================================================================== * * This product includes cryptographic software written by Eric Young * (eay@cryptsoft.com). This product includes software written by Tim * Hudson (tjh@cryptsoft.com). * */"; "OpenSSL6" = " Original SSLeay License -----------------------"; "OpenSSL7" = "/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) * All rights reserved. * * This package is an SSL implementation written * by Eric Young (eay@cryptsoft.com). * The implementation was written so as to conform with Netscapes SSL. * * This library is free for commercial and non-commercial use as long as * the following conditions are aheared to. The following conditions * apply to all code found in this distribution, be it the RC4, RSA, * lhash, DES, etc., code; not just the SSL code. The SSL documentation * included with this distribution is covered by the same copyright terms * except that the holder is Tim Hudson (tjh@cryptsoft.com). * * Copyright remains Eric Young's, and as such any Copyright notices in * the code are not to be removed. * If this package is used in a product, Eric Young should be given attribution * as the author of the parts of the library used. * This can be in the form of a textual message at program startup or * in documentation (online or textual) provided with the package. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * 3. All advertising materials mentioning features or use of this software * must display the following acknowledgement: * 'This product includes cryptographic software written by * Eric Young (eay@cryptsoft.com)' * The word 'cryptographic' can be left out if the rouines from the library * being used are not cryptographic related :-). * 4. If you include any Windows specific code (or a derivative thereof) from * the apps directory (application code) you must include an acknowledgement: * 'This product includes software written by Tim Hudson (tjh@cryptsoft.com)' * * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF * SUCH DAMAGE. * * The licence and distribution terms for any publically available version or * derivative of this code cannot be changed. i.e. this code cannot simply be * copied and put under another distribution licence * [including the GNU Public Licence.] */"; "SQLCipher" = "SQLCipher"; "SQLCipher2" = "Copyright (c) 2008, ZETETIC LLC All rights reserved."; "SQLCipher3" = "Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the ZETETIC LLC nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission."; "SQLCipher4" = "THIS SOFTWARE IS PROVIDED BY ZETETIC LLC ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. "; "SSKeychain" = "SSKeyChain"; "SSKeychain2" = "Copyright (c) 2010-2014 Sam Soffes, http://soff.es"; "SSKeychain3" = "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:"; "SSKeychain4" = "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software."; "SSKeychain5" = "THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "; "SocketRocket" = "SocketRocket https://github.com/square/SocketRocket"; "SocketRocket2" = " Copyright 2012 Square Inc."; "SocketRocket3" = " Licensed under the Apache License, Version 2.0 (the 'License'); you may not use this file except in compliance with the License. You may obtain a copy of the License at"; "SocketRocket4" = " http://www.apache.org/licenses/LICENSE-2.0"; "SocketRocket5" = " Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License."; "YapDatabase" = "YapDatabase https://github.com/yapstudios/YapDatabase"; "YapDatabase2" = "Software License Agreement (BSD License)"; "YapDatabase3" = "Copyright (c) 2013, yap.TV Inc. All rights reserved."; "YapDatabase4" = "Redistribution and use of this software in source and binary forms, with or without modification, are permitted provided that the following conditions are met:"; "YapDatabase5" = "* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer."; "YapDatabase6" = "* Neither the name of yap.TV nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission of yap.TV Inc."; "YapDatabase7" = "THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."; "libPhoneNumber-iOS" = "libPhoneNumber https://github.com/iziz/libPhoneNumber-iOS"; "libPhoneNumber-iOS2" = " Apache License Version 2.0, January 2004 http://www.apache.org/licenses/"; "libPhoneNumber-iOS3" = " TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION"; "libPhoneNumber-iOS4" = " 1. Definitions."; "libPhoneNumber-iOS5" = " 'License' shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document."; "libPhoneNumber-iOS6" = " 'Licensor' shall mean the copyright owner or entity authorized by the copyright owner that is granting the License."; "libPhoneNumber-iOS7" = " 'Legal Entity' shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, 'control' means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity."; "libPhoneNumber-iOS8" = " 'You' (or 'Your') shall mean an individual or Legal Entity exercising permissions granted by this License."; "libPhoneNumber-iOS9" = " 'Source' form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files."; "libPhoneNumber-iOS10" = " 'Object' form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types."; "libPhoneNumber-iOS11" = " 'Work' shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below)."; "libPhoneNumber-iOS12" = " 'Derivative Works' shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof."; "libPhoneNumber-iOS13" = " 'Contribution' shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, 'submitted' means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as 'Not a Contribution.'"; "libPhoneNumber-iOS14" = " 'Contributor' shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work."; "libPhoneNumber-iOS15" = " 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form."; "libPhoneNumber-iOS16" = " 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed."; "libPhoneNumber-iOS17" = " 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:"; "libPhoneNumber-iOS18" = " (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and"; "libPhoneNumber-iOS19" = " (b) You must cause any modified files to carry prominent notices stating that You changed the files; and"; "libPhoneNumber-iOS20" = " (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and"; "libPhoneNumber-iOS21" = " (d) If the Work includes a 'NOTICE' text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License."; "libPhoneNumber-iOS22" = " You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License."; "libPhoneNumber-iOS23" = " 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions."; "libPhoneNumber-iOS24" = " 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file."; "libPhoneNumber-iOS25" = " 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License."; "libPhoneNumber-iOS26" = " 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages."; "libPhoneNumber-iOS27" = " 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability."; "libPhoneNumber-iOS28" = " END OF TERMS AND CONDITIONS "; \ No newline at end of file diff --git a/Session/Meta/Signal-Bridging-Header.h b/Session/Meta/Signal-Bridging-Header.h index 26368c90c..a745cd256 100644 --- a/Session/Meta/Signal-Bridging-Header.h +++ b/Session/Meta/Signal-Bridging-Header.h @@ -2,38 +2,9 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -#import -#import -#import - -// Separate iOS Frameworks from other imports. #import "AVAudioSession+OWS.h" #import "OWSAudioPlayer.h" #import "OWSBezierPathView.h" #import "OWSMessageTimerView.h" #import "OWSWindowManager.h" #import "MainAppContext.h" -#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/Session/Meta/Translations/.tx/config b/Session/Meta/Translations/.tx/config deleted file mode 100644 index 16f90c7c6..000000000 --- a/Session/Meta/Translations/.tx/config +++ /dev/null @@ -1,8 +0,0 @@ -[main] -host = https://www.transifex.com - -[signal-ios.localizablestrings-30] -file_filter = .lproj/Localizable.strings -source_file = en.lproj/Localizable.strings -source_lang = en - diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/an_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/an_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/an_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ar_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ar_translation deleted file mode 100644 index 0a4f595fe..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ar_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/bg_BG_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/bg_BG_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/bg_BG_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ca_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ca_translation deleted file mode 100644 index fe5184d7e..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ca_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/cs_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/cs_translation deleted file mode 100644 index 3510a37a0..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/cs_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/da_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/da_translation deleted file mode 100644 index ee9d886ec..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/da_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/de_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/de_translation deleted file mode 100644 index 7c2aded04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/de_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/es_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/es_translation deleted file mode 100644 index de26ecf0a..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/es_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/eu_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/eu_translation deleted file mode 100644 index 957b6f4af..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/eu_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fa_IR_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fa_IR_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fa_IR_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fa_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fa_translation deleted file mode 100644 index d8f9c028a..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fa_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fi_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fi_translation deleted file mode 100644 index 83a588766..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fi_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fil_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fil_translation deleted file mode 100644 index 5f547d809..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fil_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fr_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fr_translation deleted file mode 100644 index 7c01668df..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/fr_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/he_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/he_translation deleted file mode 100644 index 0c0a94c3e..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/he_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/hu_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/hu_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/hu_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/it_IT_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/it_IT_translation deleted file mode 100644 index d7cc22c29..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/it_IT_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ja_JP_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ja_JP_translation deleted file mode 100644 index 8f7598cf6..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ja_JP_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/lv_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/lv_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/lv_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/nb_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/nb_translation deleted file mode 100644 index 0160ddddb..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/nb_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/nl_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/nl_translation deleted file mode 100644 index cc71f8443..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/nl_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/pl_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/pl_translation deleted file mode 100644 index ac77e5f46..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/pl_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/pt_BR_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/pt_BR_translation deleted file mode 100644 index eaae649f1..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/pt_BR_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ro_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ro_translation deleted file mode 100644 index 4879df64e..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ro_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ru_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ru_translation deleted file mode 100644 index 1cd42de68..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ru_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/sl_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/sl_translation deleted file mode 100644 index 3c582b3da..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/sl_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/sq_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/sq_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/sq_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/sv_SE_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/sv_SE_translation deleted file mode 100644 index 5a7cfb3cb..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/sv_SE_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ta_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ta_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/ta_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/tr_TR_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/tr_TR_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/tr_TR_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/uk_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/uk_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/uk_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/zh_CN_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/zh_CN_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/zh_CN_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/zh_TW.Big5_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/zh_TW.Big5_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/zh_TW.Big5_translation and /dev/null differ diff --git a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/zh_TW_translation b/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/zh_TW_translation deleted file mode 100644 index 896593f04..000000000 Binary files a/Session/Meta/Translations/.tx/signal-ios.localizablestrings-30/zh_TW_translation and /dev/null differ diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index 1d590e0d3..36e57b643 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Fehler"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index cf97bdb51..fb8c06328 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Error"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index ee4801866..26cd6fcc7 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Fallo"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index 4b1b87715..591ed7146 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "شما درخواست پیام جدیدی دارید"; "TXT_HIDE_TITLE" = "پنهان کردن"; "TXT_DELETE_ACCEPT" = "پذیرفتن"; -"TXT_DECLINE_TITLE" = "رد کردن"; "TXT_BLOCK_USER_TITLE" = "مسدود کردن کاربر"; "ALERT_ERROR_TITLE" = "خطا"; "modal_call_permission_request_title" = "دسترسی تلفن مورد نیاز است"; @@ -415,6 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "متاسفانه خطایی رخ داده است"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "لطفا بعدا دوباره تلاش کنید"; "LOADING_CONVERSATIONS" = "درحال بارگزاری پیام ها..."; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "هنگام بهینه‌سازی پایگاه داده خطایی روی داد\n\nشما می‌توانید گزارش‌های برنامه خود را صادر کنید تا بتوانید برای عیب‌یابی به اشتراک بگذارید یا می‌توانید دستگاه خود را بازیابی کنید\n\nهشدار: بازیابی دستگاه شما منجر به از دست رفتن داده‌های قدیمی‌تر از دو هفته می‌شود."; "RECOVERY_PHASE_ERROR_GENERIC" = "مشکلی پیش آمد. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید."; "RECOVERY_PHASE_ERROR_LENGTH" = "به نظر می رسد کلمات کافی وارد نکرده اید. لطفاً عبارت بازیابی خود را بررسی کنید و دوباره امتحان کنید."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index a75e1070c..d583693df 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "Sinulla on uusi viestipyyntö"; "TXT_HIDE_TITLE" = "Piilota"; "TXT_DELETE_ACCEPT" = "Hyväksy"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Virhe"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index f23971351..562da051b 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "Vous avez une nouvelle demande de message"; "TXT_HIDE_TITLE" = "Masquer"; "TXT_DELETE_ACCEPT" = "Accepter"; -"TXT_DECLINE_TITLE" = "Refuser"; "TXT_BLOCK_USER_TITLE" = "Bloquer Utilisateur"; "ALERT_ERROR_TITLE" = "Erreur"; "modal_call_permission_request_title" = "Autorisation d'appel requise"; @@ -415,6 +414,10 @@ "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oups, une erreur est survenue"; "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard"; "LOADING_CONVERSATIONS" = "Chargement des conversations..."; +"DATABASE_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines"; "RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; "RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Quitter..."; "group_leave_error" = "Impossible de quitter le groupe!"; "group_unable_to_leave" = "Impossible de quitter le groupe, veuillez réessayer"; -"delete_conversation_confirmation_alert_message" = "Êtes-vous sûr de vouloir supprimer votre conversation avec %@ ?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Supprimer conversation"; +"delete_conversation_confirmation_alert_message" = "Êtes-vous sûr de vouloir supprimer votre conversation avec %@ ?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index ca058e22a..c5837e958 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Error"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index e6cd0d2a3..60b5da861 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Greška"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index c620b6ae6..8a45a00b0 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Galat"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index 2c1b9996f..547d49c04 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "Hai una nuova richiesta di messaggio"; "TXT_HIDE_TITLE" = "Nascondi"; "TXT_DELETE_ACCEPT" = "Accetta"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Errore"; "modal_call_permission_request_title" = "Permessi Di Chiamata Richiesti"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index cc28d1898..e380f3ea7 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "新しいリクエストがあります"; "TXT_HIDE_TITLE" = "非表示"; "TXT_DELETE_ACCEPT" = "許可"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "エラー"; "modal_call_permission_request_title" = "許可が必要です"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 477156edf..8aff0e69e 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Fout"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 69e08221d..bc5252c48 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "Masz nowe żądanie wiadomości"; "TXT_HIDE_TITLE" = "Ukryj"; "TXT_DELETE_ACCEPT" = "Zaakceptuj"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Błąd"; "modal_call_permission_request_title" = "Wymagane uprawnienia połączenia"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index 02587c0a2..16a6b845d 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Erro"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index d3f3b00b7..a5968d207 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Скрыть"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Ошибка"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index c33b738d8..d0f8738c0 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Error"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 4f924c6fe..8df3501ac 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "Máte novú žiadosť o správu"; "TXT_HIDE_TITLE" = "Skryť"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Error"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index 2fa76cecb..1d3d86dff 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Fel"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index 6aae143ff..581fc3e1c 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "ข้อผิดพลาด"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index a146cdbf7..07311c2ab 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Error"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 8725b7d5f..8fd5c3e27 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "You have a new message request"; "TXT_HIDE_TITLE" = "Hide"; "TXT_DELETE_ACCEPT" = "Accept"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "Error"; "modal_call_permission_request_title" = "Call Permissions Required"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index d4f9a737b..7f63ffbf9 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -407,7 +407,6 @@ "MESSAGE_REQUESTS_NOTIFICATION" = "您有一个新的消息请求"; "TXT_HIDE_TITLE" = "隐藏"; "TXT_DELETE_ACCEPT" = "接受"; -"TXT_DECLINE_TITLE" = "Decline"; "TXT_BLOCK_USER_TITLE" = "Block User"; "ALERT_ERROR_TITLE" = "错误"; "modal_call_permission_request_title" = "请授予通话权限"; @@ -415,6 +414,10 @@ "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_STARTUP_FAILED" = "An error occurred when opening the database\n\nYou can export your application logs to share for troubleshooting or you can try to restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; +"APP_STARTUP_TIMEOUT" = "The app is taking a long time to start\n\nYou can continue to wait for the app to start, export your application logs to share for troubleshooting or you can try to open the app again"; +"APP_STARTUP_EXIT" = "Exit"; +"DATABASE_RESTORE_FAILED" = "An error occurred when opening the restored database\n\nYou can export your application logs to share for troubleshooting but to continue to use Session you may need to reinstall"; "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"; "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."; @@ -621,8 +624,25 @@ "group_you_leaving" = "Leaving..."; "group_leave_error" = "Failed to leave Group!"; "group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"delete_group_confirmation_alert_title" = "Delete Group"; +"delete_group_confirmation_alert_message" = "Are you sure you want to delete %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; +"hide_note_to_self_confirmation_alert_title" = "Hide Note to Self"; +"hide_note_to_self_confirmation_alert_message" = "Are you sure you want to hide %@?"; "update_profile_modal_title" = "Set Display Picture"; -"update_profile_modal_upload" = "Upload"; +"update_profile_modal_save" = "Save"; "update_profile_modal_remove" = "Remove"; +"update_profile_modal_remove_error_title" = "Unable to remove avatar image"; +"update_profile_modal_max_size_error_title" = "Maximum File Size Exceeded"; +"update_profile_modal_max_size_error_message" = "Please select a smaller photo and try again"; +"update_profile_modal_error_title" = "Couldn't Update Profile"; +"update_profile_modal_error_message" = "Please check your internet connection and try again"; +"CONTACT_NICKNAME_PLACEHOLDER" = "Enter a name"; +"MARK_AS_READ" = "Mark Read"; +"MARK_AS_UNREAD" = "Mark Unread"; +"UNREAD_MESSAGES" = "Unread Messages"; +"CONVERSATION_EMPTY_STATE" = "You have no messages from %@. Send a message to start the conversation!"; +"CONVERSATION_EMPTY_STATE_READ_ONLY" = "There are no messages in %@."; +"CONVERSATION_EMPTY_STATE_NOTE_TO_SELF" = "You have no messages in %@."; +"USER_CONFIG_OUTDATED_WARNING" = "Some of your devices are using outdated versions. Syncing may be unreliable until they are updated."; diff --git a/Session/Notifications/AppNotifications.swift b/Session/Notifications/AppNotifications.swift index 9a8b97a2b..8f4bb263a 100644 --- a/Session/Notifications/AppNotifications.swift +++ b/Session/Notifications/AppNotifications.swift @@ -1,10 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SessionMessagingKit import SignalUtilitiesKit +import SignalCoreKit /// There are two primary components in our system notification integration: /// @@ -36,6 +37,7 @@ enum AppNotificationAction: CaseIterable { struct AppNotificationUserInfoKey { static let threadId = "Signal.AppNotificationsUserInfoKey.threadId" + static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" static let callBackNumber = "Signal.AppNotificationsUserInfoKey.callBackNumber" static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId" static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" @@ -87,7 +89,7 @@ let kAudioNotificationsThrottleInterval: TimeInterval = 5 protocol NotificationPresenterAdaptee: AnyObject { - func registerNotificationSettings() -> Promise + func registerNotificationSettings() -> AnyPublisher func notify( category: AppNotificationCategory, @@ -98,6 +100,7 @@ protocol NotificationPresenterAdaptee: AnyObject { sound: Preferences.Sound?, threadVariant: SessionThread.Variant, threadName: String, + applicationState: UIApplication.State, replacingIdentifier: String? ) @@ -115,7 +118,8 @@ extension NotificationPresenterAdaptee { previewType: Preferences.NotificationPreviewType, sound: Preferences.Sound?, threadVariant: SessionThread.Variant, - threadName: String + threadName: String, + applicationState: UIApplication.State ) { notify( category: category, @@ -126,32 +130,31 @@ extension NotificationPresenterAdaptee { sound: sound, threadVariant: threadVariant, threadName: threadName, + applicationState: applicationState, replacingIdentifier: nil ) } } -@objc(OWSNotificationPresenter) -public class NotificationPresenter: NSObject, NotificationsProtocol { - - private let adaptee: NotificationPresenterAdaptee - - @objc - public override init() { - self.adaptee = UserNotificationPresenterAdaptee() - - super.init() +public class NotificationPresenter: NotificationsProtocol { + private let adaptee: NotificationPresenterAdaptee = UserNotificationPresenterAdaptee() + public init() { SwiftSingletons.register(self) } // MARK: - Presenting Notifications - func registerNotificationSettings() -> Promise { + func registerNotificationSettings() -> AnyPublisher { return adaptee.registerNotificationSettings() } - public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { + public func notifyUser( + _ db: Database, + for interaction: Interaction, + in thread: SessionThread, + applicationState: UIApplication.State + ) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // Ensure we should be showing a notification for the thread @@ -161,7 +164,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // Try to group notifications for interactions from open groups let identifier: String = interaction.notificationIdentifier( - shouldGroupMessagesForThread: (thread.variant == .openGroup) + shouldGroupMessagesForThread: (thread.variant == .community) ) // While batch processing, some of the necessary changes have not been commited. @@ -201,7 +204,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { case .contact: notificationTitle = (isMessageRequest ? "Session" : senderName) - case .closedGroup, .openGroup: + case .legacyGroup, .group, .community: notificationTitle = String( format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, @@ -230,50 +233,68 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // "no longer verified". let category = AppNotificationCategory.incomingMessage - let userInfo = [ - AppNotificationUserInfoKey.threadId: thread.id + let userInfo: [AnyHashable: Any] = [ + AppNotificationUserInfoKey.threadId: thread.id, + AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue ] let userPublicKey: String = getUserHexEncodedPublicKey(db) - let userBlindedKey: String? = SessionThread.getUserHexEncodedBlindedKey( + let userBlinded15Key: String? = SessionThread.getUserHexEncodedBlindedKey( db, threadId: thread.id, - threadVariant: thread.variant + threadVariant: thread.variant, + blindingPrefix: .blinded15 + ) + let userBlinded25Key: String? = SessionThread.getUserHexEncodedBlindedKey( + db, + threadId: thread.id, + threadVariant: thread.variant, + blindingPrefix: .blinded25 ) let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) - DispatchQueue.main.async { - let sound: Preferences.Sound? = self.requestSound( - thread: thread, - fallbackSound: fallbackSound - ) - - notificationBody = MentionUtilities.highlightMentionsNoAttributes( - in: (notificationBody ?? ""), - threadVariant: thread.variant, - currentUserPublicKey: userPublicKey, - currentUserBlindedPublicKey: userBlindedKey - ) - - self.adaptee.notify( - category: category, - title: notificationTitle, - body: (notificationBody ?? ""), - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: groupName, - replacingIdentifier: identifier - ) - } + let sound: Preferences.Sound? = requestSound( + thread: thread, + fallbackSound: fallbackSound, + applicationState: applicationState + ) + + notificationBody = MentionUtilities.highlightMentionsNoAttributes( + in: (notificationBody ?? ""), + threadVariant: thread.variant, + currentUserPublicKey: userPublicKey, + currentUserBlinded15PublicKey: userBlinded15Key, + currentUserBlinded25PublicKey: userBlinded25Key + ) + + self.adaptee.notify( + category: category, + title: notificationTitle, + body: (notificationBody ?? ""), + userInfo: userInfo, + previewType: previewType, + sound: sound, + threadVariant: thread.variant, + threadName: groupName, + applicationState: applicationState, + replacingIdentifier: identifier + ) } - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { + public func notifyUser( + _ db: Database, + forIncomingCall interaction: Interaction, + in thread: SessionThread, + applicationState: UIApplication.State + ) { // No call notifications for muted or group threads guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + thread.variant != .legacyGroup && + thread.variant != .group && + thread.variant != .community + else { return } guard interaction.variant == .infoCall, let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), @@ -290,8 +311,9 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] .defaulting(to: .nameAndPreview) - let userInfo = [ - AppNotificationUserInfoKey.threadId: thread.id + let userInfo: [AnyHashable: Any] = [ + AppNotificationUserInfoKey.threadId: thread.id, + AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue ] let notificationTitle: String = "Session" @@ -315,33 +337,41 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) + let sound = self.requestSound( + thread: thread, + fallbackSound: fallbackSound, + applicationState: applicationState + ) - DispatchQueue.main.async { - let sound = self.requestSound( - thread: thread, - fallbackSound: fallbackSound - ) - - self.adaptee.notify( - category: category, - title: notificationTitle, - body: (notificationBody ?? ""), - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: senderName, - replacingIdentifier: UUID().uuidString - ) - } + self.adaptee.notify( + category: category, + title: notificationTitle, + body: (notificationBody ?? ""), + userInfo: userInfo, + previewType: previewType, + sound: sound, + threadVariant: thread.variant, + threadName: senderName, + applicationState: applicationState, + replacingIdentifier: UUID().uuidString + ) } - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) { + public func notifyUser( + _ db: Database, + forReaction reaction: Reaction, + in thread: SessionThread, + applicationState: UIApplication.State + ) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // No reaction notifications for muted, group threads or message requests guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + thread.variant != .legacyGroup && + thread.variant != .group && + thread.variant != .community + else { return } guard !isMessageRequest else { return } let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant) @@ -359,8 +389,9 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let category = AppNotificationCategory.incomingMessage - let userInfo = [ - AppNotificationUserInfoKey.threadId: thread.id + let userInfo: [AnyHashable: Any] = [ + AppNotificationUserInfoKey.threadId: thread.id, + AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue ] let threadName: String = SessionThread.displayName( @@ -371,28 +402,31 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { ) let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) - - DispatchQueue.main.async { - let sound = self.requestSound( - thread: thread, - fallbackSound: fallbackSound - ) - - self.adaptee.notify( - category: category, - title: notificationTitle, - body: notificationBody, - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: threadName, - replacingIdentifier: UUID().uuidString - ) - } + let sound = self.requestSound( + thread: thread, + fallbackSound: fallbackSound, + applicationState: applicationState + ) + + self.adaptee.notify( + category: category, + title: notificationTitle, + body: notificationBody, + userInfo: userInfo, + previewType: previewType, + sound: sound, + threadVariant: thread.variant, + threadName: threadName, + applicationState: applicationState, + replacingIdentifier: UUID().uuidString + ) } - public func notifyForFailedSend(_ db: Database, in thread: SessionThread) { + public func notifyForFailedSend( + _ db: Database, + in thread: SessionThread, + applicationState: UIApplication.State + ) { let notificationTitle: String? let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] .defaulting(to: .defaultPreviewType) @@ -418,29 +452,29 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { let notificationBody = NotificationStrings.failedToSendBody - let userInfo = [ - AppNotificationUserInfoKey.threadId: thread.id + let userInfo: [AnyHashable: Any] = [ + AppNotificationUserInfoKey.threadId: thread.id, + AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue ] let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) - - DispatchQueue.main.async { - let sound: Preferences.Sound? = self.requestSound( - thread: thread, - fallbackSound: fallbackSound - ) - - self.adaptee.notify( - category: .errorMessage, - title: notificationTitle, - body: notificationBody, - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: threadName - ) - } + let sound: Preferences.Sound? = self.requestSound( + thread: thread, + fallbackSound: fallbackSound, + applicationState: applicationState + ) + + self.adaptee.notify( + category: .errorMessage, + title: notificationTitle, + body: notificationBody, + userInfo: userInfo, + previewType: previewType, + sound: sound, + threadVariant: thread.variant, + threadName: threadName, + applicationState: applicationState + ) } @objc @@ -462,32 +496,30 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { // MARK: - - var mostRecentNotifications = TruncatedList(maxLength: kAudioNotificationsThrottleCount) + var mostRecentNotifications: Atomic> = Atomic(TruncatedList(maxLength: kAudioNotificationsThrottleCount)) - private func requestSound(thread: SessionThread, fallbackSound: Preferences.Sound) -> Preferences.Sound? { - guard checkIfShouldPlaySound() else { - return nil - } + private func requestSound( + thread: SessionThread, + fallbackSound: Preferences.Sound, + applicationState: UIApplication.State + ) -> Preferences.Sound? { + guard checkIfShouldPlaySound(applicationState: applicationState) else { return nil } return (thread.notificationSound ?? fallbackSound) } - private func checkIfShouldPlaySound() -> Bool { - AssertIsOnMainThread() - - guard UIApplication.shared.applicationState == .active else { return true } + private func checkIfShouldPlaySound(applicationState: UIApplication.State) -> Bool { + guard applicationState == .active else { return true } guard Storage.shared[.playNotificationSoundInForeground] else { return false } let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) let recentThreshold = nowMs - UInt64(kAudioNotificationsThrottleInterval * Double(kSecondInMs)) - let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold } + let recentNotifications = mostRecentNotifications.wrappedValue.filter { $0 > recentThreshold } - guard recentNotifications.count < kAudioNotificationsThrottleCount else { - return false - } + guard recentNotifications.count < kAudioNotificationsThrottleCount else { return false } - mostRecentNotifications.append(nowMs) + mostRecentNotifications.mutate { $0.append(nowMs) } return true } } @@ -504,118 +536,149 @@ class NotificationActionHandler { // MARK: - - func markAsRead(userInfo: [AnyHashable: Any]) throws -> Promise { + func markAsRead(userInfo: [AnyHashable: Any]) -> AnyPublisher { guard let threadId: String = userInfo[AppNotificationUserInfoKey.threadId] as? String else { - throw NotificationError.failDebug("threadId was unexpectedly nil") + return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) + .eraseToAnyPublisher() + } + + guard Storage.shared.read({ db in try SessionThread.exists(db, id: threadId) }) == true else { + return Fail(error: NotificationError.failDebug("unable to find thread with id: \(threadId)")) + .eraseToAnyPublisher() } - 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) + return markAsRead(threadId: threadId) } - func reply(userInfo: [AnyHashable: Any], replyText: String) throws -> Promise { + func reply( + userInfo: [AnyHashable: Any], + replyText: String, + applicationState: UIApplication.State + ) -> AnyPublisher { guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { - throw NotificationError.failDebug("threadId was unexpectedly nil") + return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) + .eraseToAnyPublisher() } - + 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 Fail(error: NotificationError.failDebug("unable to find thread with id: \(threadId)")) + .eraseToAnyPublisher() } - 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: SnodeAPI.currentOffsetTimestampMs(), - hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText), - expiresInSeconds: try? DisappearingMessagesConfiguration - .select(.durationSeconds) - .filter(id: threadId) - .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) - .asRequest(of: TimeInterval.self) - .fetchOne(db) - ).inserted(db) - - try Interaction.markAsRead( - db, - interactionId: interaction.id, - threadId: thread.id, - threadVariant: thread.variant, - 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) + return Storage.shared + .writePublisher { db in + let interaction: Interaction = try Interaction( + threadId: threadId, + authorId: getUserHexEncodedPublicKey(db), + variant: .standardOutgoing, + body: replyText, + timestampMs: SnodeAPI.currentOffsetTimestampMs(), + hasMention: Interaction.isUserMentioned(db, threadId: threadId, body: replyText), + expiresInSeconds: try? DisappearingMessagesConfiguration + .select(.durationSeconds) + .filter(id: threadId) + .filter(DisappearingMessagesConfiguration.Columns.isEnabled == true) + .asRequest(of: TimeInterval.self) + .fetchOne(db) + ).inserted(db) + + try Interaction.markAsRead( + db, + interactionId: interaction.id, + threadId: threadId, + threadVariant: thread.variant, + includingOlder: true, + trySendReadReceipt: try SessionThread.canSendReadReceipt( + db, + threadId: threadId, + threadVariant: thread.variant + ) + ) + + return try MessageSender.preparedSendData( + db, + interaction: interaction, + threadId: threadId, + threadVariant: thread.variant + ) } - - seal.reject(error) - } - .retainUntilComplete() - - return promise + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: + Storage.shared.read { [weak self] db in + self?.notificationPresenter.notifyForFailedSend( + db, + in: thread, + applicationState: applicationState + ) + } + } + } + ) + .eraseToAnyPublisher() } - func showThread(userInfo: [AnyHashable: Any]) throws -> Promise { - guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { - return showHomeVC() - } + func showThread(userInfo: [AnyHashable: Any]) -> AnyPublisher { + guard + let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String, + let threadVariantRaw = userInfo[AppNotificationUserInfoKey.threadVariantRaw] as? Int, + let threadVariant: SessionThread.Variant = SessionThread.Variant(rawValue: threadVariantRaw) + else { return showHomeVC() } // 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: Bool = (UIApplication.shared.applicationState == .active) - SessionApp.presentConversation(for: threadId, animated: shouldAnimate) - return Promise.value(()) - } - - func showHomeVC() -> Promise { - SessionApp.showHomeView() - return Promise.value(()) - } - - 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, - threadVariant: thread.variant, - includingOlder: true, - trySendReadReceipt: true - ) - }, - completion: { _, result in - switch result { - case .success: seal.fulfill(()) - case .failure(let error): seal.reject(error) - } - } + SessionApp.presentConversationCreatingIfNeeded( + for: threadId, + variant: threadVariant, + dismissing: nil, + animated: (UIApplication.shared.applicationState == .active) ) - return promise + return Just(()) + .eraseToAnyPublisher() + } + + func showHomeVC() -> AnyPublisher { + SessionApp.showHomeView() + return Just(()) + .eraseToAnyPublisher() + } + + private func markAsRead(threadId: String) -> AnyPublisher { + return Storage.shared + .writePublisher { db in + guard + let threadVariant: SessionThread.Variant = try SessionThread + .filter(id: threadId) + .select(.variant) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db), + let lastInteractionId: Int64 = try Interaction + .select(.id) + .filter(Interaction.Columns.threadId == threadId) + .order(Interaction.Columns.timestampMs.desc) + .asRequest(of: Int64.self) + .fetchOne(db) + else { throw NotificationError.failDebug("unable to required thread info: \(threadId)") } + + try Interaction.markAsRead( + db, + interactionId: lastInteractionId, + threadId: threadId, + threadVariant: threadVariant, + includingOlder: true, + trySendReadReceipt: try SessionThread.canSendReadReceipt( + db, + threadId: threadId, + threadVariant: threadVariant + ) + ) + } + .eraseToAnyPublisher() } } diff --git a/Session/Notifications/PushRegistrationManager.swift b/Session/Notifications/PushRegistrationManager.swift index 171ae9617..d6eb260be 100644 --- a/Session/Notifications/PushRegistrationManager.swift +++ b/Session/Notifications/PushRegistrationManager.swift @@ -1,17 +1,17 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit +import Combine import PushKit -import SignalUtilitiesKit import GRDB +import SignalUtilitiesKit +import SignalCoreKit public enum PushRegistrationError: Error { case assertionError(description: String) case pushNotSupported(description: String) case timeout + case publisherNoLongerExists } /** @@ -40,63 +40,66 @@ public enum PushRegistrationError: Error { SwiftSingletons.register(self) } - private var vanillaTokenPromise: Promise? - private var vanillaTokenResolver: Resolver? + private var vanillaTokenPublisher: AnyPublisher? + private var vanillaTokenResolver: ((Result) -> ())? private var voipRegistry: PKPushRegistry? - private var voipTokenPromise: Promise? - private var voipTokenResolver: Resolver? + private var voipTokenPublisher: AnyPublisher? + private var voipTokenResolver: ((Result) -> ())? - // MARK: Public interface + // MARK: - Public interface - public func requestPushTokens() -> Promise<(pushToken: String, voipToken: String)> { + public func requestPushTokens() -> AnyPublisher<(pushToken: String, voipToken: String), Error> { Logger.info("") + + return registerUserNotificationSettings() + .subscribe(on: DispatchQueue.global(qos: .default)) + .receive(on: DispatchQueue.main) // MUST be on main thread + .setFailureType(to: Error.self) + .tryFlatMap { _ -> AnyPublisher<(pushToken: String, voipToken: String), Error> in + #if targetEnvironment(simulator) + throw PushRegistrationError.pushNotSupported(description: "Push not supported on simulators") + #endif - return firstly { () -> Promise in - self.registerUserNotificationSettings() - }.then { (_) -> Promise<(pushToken: String, voipToken: String)> in - #if targetEnvironment(simulator) - throw PushRegistrationError.pushNotSupported(description: "Push not supported on simulators") - #endif - - return self.registerForVanillaPushToken().then { vanillaPushToken -> Promise<(pushToken: String, voipToken: String)> in - self.registerForVoipPushToken().map { voipPushToken in - (pushToken: vanillaPushToken, voipToken: voipPushToken ?? "") - } + return self.registerForVanillaPushToken() + .flatMap { vanillaPushToken -> AnyPublisher<(pushToken: String, voipToken: String), Error> in + self.registerForVoipPushToken() + .map { voipPushToken in (vanillaPushToken, (voipPushToken ?? "")) } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } - } + .eraseToAnyPublisher() } // MARK: Vanilla push token // Vanilla push token is obtained from the system via AppDelegate - @objc public func didReceiveVanillaPushToken(_ tokenData: Data) { guard let vanillaTokenResolver = self.vanillaTokenResolver else { - owsFailDebug("promise completion in \(#function) unexpectedly nil") + owsFailDebug("publisher completion in \(#function) unexpectedly nil") return } - vanillaTokenResolver.fulfill(tokenData) + vanillaTokenResolver(Result.success(tokenData)) } // Vanilla push token is obtained from the system via AppDelegate @objc public func didFailToReceiveVanillaPushToken(error: Error) { guard let vanillaTokenResolver = self.vanillaTokenResolver else { - owsFailDebug("promise completion in \(#function) unexpectedly nil") + owsFailDebug("publisher completion in \(#function) unexpectedly nil") return } - vanillaTokenResolver.reject(error) + vanillaTokenResolver(Result.failure(error)) } // MARK: helpers // User notification settings must be registered *before* AppDelegate will // return any requested push tokens. - public func registerUserNotificationSettings() -> Promise { - AssertIsOnMainThread() + public func registerUserNotificationSettings() -> AnyPublisher { return notificationPresenter.registerNotificationSettings() } @@ -125,52 +128,75 @@ public enum PushRegistrationError: Error { return true } - private func registerForVanillaPushToken() -> Promise { + // FIXME: Might be nice to try to avoid having this required to run on the main thread (follow a similar approach to the 'SyncPushTokensJob' & `Atomic`?) + private func registerForVanillaPushToken() -> AnyPublisher { AssertIsOnMainThread() - - guard self.vanillaTokenPromise == nil else { - let promise = vanillaTokenPromise! - assert(promise.isPending) - return promise.map { $0.hexEncodedString } + + // Use the existing publisher if it exists + if let vanillaTokenPublisher: AnyPublisher = self.vanillaTokenPublisher { + return vanillaTokenPublisher + .map { $0.toHexString() } + .eraseToAnyPublisher() } - - // No pending vanilla token yet; create a new promise - let (promise, resolver) = Promise.pending() - self.vanillaTokenPromise = promise - self.vanillaTokenResolver = resolver - + UIApplication.shared.registerForRemoteNotifications() - - let kTimeout: TimeInterval = 10 - let timeout: Promise = after(seconds: kTimeout).map { throw PushRegistrationError.timeout } - let promiseWithTimeout: Promise = race(promise, timeout) - - return promiseWithTimeout.recover { error -> Promise in - switch error { - case PushRegistrationError.timeout: - if self.isSusceptibleToFailedPushRegistration { - // If we've timed out on a device known to be susceptible to failures, quit trying - // so the user doesn't remain indefinitely hung for no good reason. - throw PushRegistrationError.pushNotSupported(description: "Device configuration disallows push notifications") - } else { - // Sometimes registration can just take a while. - // If we're not on a device known to be susceptible to push registration failure, - // just return the original promise. - return promise - } - default: - throw error - } - }.map { (pushTokenData: Data) -> String in - if self.isSusceptibleToFailedPushRegistration { - // Sentinal in case this bug is fixed - OWSLogger.debug("Device was unexpectedly able to complete push registration even though it was susceptible to failure.") - } - - return pushTokenData.hexEncodedString - }.ensure { - self.vanillaTokenPromise = nil + + // No pending vanilla token yet; create a new publisher + let publisher: AnyPublisher = Deferred { + Future { self.vanillaTokenResolver = $0 } } + .eraseToAnyPublisher() + self.vanillaTokenPublisher = publisher + + return publisher + .timeout( + .seconds(10), + scheduler: DispatchQueue.main, + customError: { PushRegistrationError.timeout } + ) + .catch { error -> AnyPublisher in + switch error { + case PushRegistrationError.timeout: + guard self.isSusceptibleToFailedPushRegistration else { + // Sometimes registration can just take a while. + // If we're not on a device known to be susceptible to push registration failure, + // just return the original publisher. + guard let originalPublisher: AnyPublisher = self.vanillaTokenPublisher else { + return Fail(error: PushRegistrationError.publisherNoLongerExists) + .eraseToAnyPublisher() + } + + return originalPublisher + } + + // If we've timed out on a device known to be susceptible to failures, quit trying + // so the user doesn't remain indefinitely hung for no good reason. + return Fail( + error: PushRegistrationError.pushNotSupported( + description: "Device configuration disallows push notifications" + ) + ).eraseToAnyPublisher() + + default: + return Fail(error: error) + .eraseToAnyPublisher() + } + } + .map { tokenData -> String in + if self.isSusceptibleToFailedPushRegistration { + // Sentinal in case this bug is fixed + OWSLogger.debug("Device was unexpectedly able to complete push registration even though it was susceptible to failure.") + } + + return tokenData.toHexString() + } + .handleEvents( + receiveCompletion: { _ in + self.vanillaTokenPublisher = nil + self.vanillaTokenResolver = nil + } + ) + .eraseToAnyPublisher() } public func createVoipRegistryIfNecessary() { @@ -178,61 +204,70 @@ public enum PushRegistrationError: Error { guard voipRegistry == nil else { return } let voipRegistry = PKPushRegistry(queue: nil) - self.voipRegistry = voipRegistry + self.voipRegistry = voipRegistry voipRegistry.desiredPushTypes = [.voIP] voipRegistry.delegate = self } - private func registerForVoipPushToken() -> Promise { + private func registerForVoipPushToken() -> AnyPublisher { AssertIsOnMainThread() - - guard self.voipTokenPromise == nil else { - let promise = self.voipTokenPromise! - return promise.map { $0?.hexEncodedString } + + // Use the existing publisher if it exists + if let voipTokenPublisher: AnyPublisher = self.voipTokenPublisher { + return voipTokenPublisher + .map { $0?.toHexString() } + .eraseToAnyPublisher() } - - // No pending voip token yet. Create a new promise - let (promise, resolver) = Promise.pending() - self.voipTokenPromise = promise - self.voipTokenResolver = resolver - + // We don't create the voip registry in init, because it immediately requests the voip token, // potentially before we're ready to handle it. createVoipRegistryIfNecessary() - - guard let voipRegistry = self.voipRegistry else { + + guard let voipRegistry: PKPushRegistry = self.voipRegistry else { owsFailDebug("failed to initialize voipRegistry") - resolver.reject(PushRegistrationError.assertionError(description: "failed to initialize voipRegistry")) - return promise.map { _ in - // coerce expected type of returned promise - we don't really care about the value, - // since this promise has been rejected. In practice this shouldn't happen - String() - } + return Fail( + error: PushRegistrationError.assertionError(description: "failed to initialize voipRegistry") + ).eraseToAnyPublisher() } - + // If we've already completed registering for a voip token, resolve it immediately, // rather than waiting for the delegate method to be called. - if let voipTokenData = voipRegistry.pushToken(for: .voIP) { + if let voipTokenData: Data = voipRegistry.pushToken(for: .voIP) { Logger.info("using pre-registered voIP token") - resolver.fulfill(voipTokenData) + return Just(voipTokenData.toHexString()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - - return promise.map { (voipTokenData: Data?) -> String? in - Logger.info("successfully registered for voip push notifications") - return voipTokenData?.hexEncodedString - }.ensure { - self.voipTokenPromise = nil + + // No pending voip token yet. Create a new publisher + let publisher: AnyPublisher = Deferred { + Future { self.voipTokenResolver = $0 } } + .eraseToAnyPublisher() + self.voipTokenPublisher = publisher + + return publisher + .map { voipTokenData -> String? in + Logger.info("successfully registered for voip push notifications") + return voipTokenData?.toHexString() + } + .handleEvents( + receiveCompletion: { _ in + self.voipTokenPublisher = nil + self.voipTokenResolver = nil + } + ) + .eraseToAnyPublisher() } - // MARK: PKPushRegistryDelegate + // MARK: - PKPushRegistryDelegate + public func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { Logger.info("") owsAssertDebug(type == .voIP) owsAssertDebug(pushCredentials.type == .voIP) - guard let voipTokenResolver = voipTokenResolver else { return } - voipTokenResolver.fulfill(pushCredentials.token) + voipTokenResolver?(Result.success(pushCredentials.token)) } // NOTE: This function MUST report an incoming call. @@ -271,7 +306,8 @@ public enum PushRegistrationError: Error { }() let call: SessionCall = SessionCall(db, for: caller, uuid: uuid, mode: .answer) - let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: caller, variant: .contact) + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: caller, variant: .contact, shouldBeVisible: nil) let interaction: Interaction = try Interaction( messageUuid: uuid, diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index e6206b300..8f6b3c261 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -1,11 +1,12 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SignalCoreKit import SessionMessagingKit import SessionUtilitiesKit +import SignalCoreKit public enum SyncPushTokensJob: JobExecutor { public static let maxFailureCount: Int = -1 @@ -22,79 +23,118 @@ public enum SyncPushTokensJob: JobExecutor { dependencies: Dependencies = Dependencies() ) { // Don't run when inactive or not in main app or if the user doesn't exist yet - guard - (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false), - Identity.userExists() - else { - deferred(job, dependencies) // Don't need to do anything if it's not the main app - return + guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { + return deferred(job, dependencies) // Don't need to do anything if it's not the main app + } + guard Identity.userCompletedRequiredOnboarding() else { + SNLog("[SyncPushTokensJob] Deferred due to incomplete registration") + return deferred(job, dependencies) } - // 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, dependencies: dependencies) + // We need to check a UIApplication setting which needs to run on the main thread so synchronously + // retrieve the value so we can continue + let isRegisteredForRemoteNotifications: Bool = { + guard !Thread.isMainThread else { + return UIApplication.shared.isRegisteredForRemoteNotifications } - return - } + + return DispatchQueue.main.sync { + return UIApplication.shared.isRegisteredForRemoteNotifications + } + }() - // Push tokens don't normally change while the app is launched, so you would assume checking once - // during launch is 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. - // - // It's also possible for a device to successfully register for push notifications but fail to - // register with Session - // - // Due to the above we want to re-register at least once every ~12 hours to ensure users will - // continue to receive push notifications - // - // In addition to this if we are custom running the job (eg. by toggling the push notification - // setting) then we should run regardless of the other settings so users have a mechanism to force - // the registration to run - let lastPushNotificationSync: Date = UserDefaults.standard[.lastPushNotificationSync] - .defaulting(to: Date.distantPast) - - guard - job.behaviour == .runOnce || - !UIApplication.shared.isRegisteredForRemoteNotifications || - Date().timeIntervalSince(lastPushNotificationSync) >= SyncPushTokensJob.maxFrequency - else { + // Apple's documentation states that we should re-register for notifications on every launch: + // https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/HandlingRemoteNotifications.html#//apple_ref/doc/uid/TP40008194-CH6-SW1 + guard job.behaviour == .runOnce || !isRegisteredForRemoteNotifications else { + SNLog("[SyncPushTokensJob] Deferred due to Fast Mode disabled") deferred(job, dependencies) // Don't need to do anything if push notifications are already registered return } - Logger.info("Re-registering for remote notifications.") + // Determine if the device has 'Fast Mode' (APNS) enabled + let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs] + + // If the job is running and 'Fast Mode' is disabled then we should try to unregister the existing + // token + guard isUsingFullAPNs else { + Just(Storage.shared[.lastRecordedPushToken]) + .setFailureType(to: Error.self) + .flatMap { lastRecordedPushToken in + if let existingToken: String = lastRecordedPushToken { + SNLog("[SyncPushTokensJob] Unregister using last recorded push token: \(redact(existingToken))") + return Just(existingToken) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + SNLog("[SyncPushTokensJob] Unregister using live token provided from device") + return PushRegistrationManager.shared.requestPushTokens() + .map { token, _ in token } + .eraseToAnyPublisher() + } + .flatMap { pushToken in PushNotificationAPI.unregister(Data(hex: pushToken)) } + .map { + // Tell the device to unregister for remote notifications (essentially try to invalidate + // the token if needed + DispatchQueue.main.sync { UIApplication.shared.unregisterForRemoteNotifications() } + + Storage.shared.write { db in + db[.lastRecordedPushToken] = nil + } + return () + } + .subscribe(on: queue) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: SNLog("[SyncPushTokensJob] Unregister Completed") + case .failure: SNLog("[SyncPushTokensJob] Unregister Failed") + } + + // We want to complete this job regardless of success or failure + success(job, false, dependencies) + } + ) + return + } // Perform device registration + Logger.info("Re-registering for remote notifications.") PushRegistrationManager.shared.requestPushTokens() - .then(on: queue) { (pushToken: String, voipToken: String) -> Promise in - let (promise, seal) = Promise.pending() - - SyncPushTokensJob.registerForPushNotifications( - pushToken: pushToken, - voipToken: voipToken, - isForcedUpdate: true, - success: { seal.fulfill(()) }, - failure: seal.reject - ) - - return promise - .done(on: queue) { _ in - Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") + .flatMap { (pushToken: String, voipToken: String) -> AnyPublisher in + PushNotificationAPI + .register( + with: Data(hex: pushToken), + publicKey: getUserHexEncodedPublicKey(), + isForcedUpdate: true + ) + .retry(3) + .handleEvents( + receiveCompletion: { result in + switch result { + case .failure(let error): + SNLog("[SyncPushTokensJob] Failed to register due to error: \(error)") + + case .finished: + Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))") + SNLog("[SyncPushTokensJob] Completed") + UserDefaults.standard[.lastPushNotificationSync] = Date() - Storage.shared.write { db in - db[.lastRecordedPushToken] = pushToken - db[.lastRecordedVoipToken] = voipToken + Storage.shared.write { db in + db[.lastRecordedPushToken] = pushToken + db[.lastRecordedVoipToken] = voipToken + } + } } - } + ) + .map { _ in () } + .eraseToAnyPublisher() } - .ensure(on: queue) { - success(job, false, dependencies) // We want to complete this job regardless of success or failure - } - .retainUntilComplete() + .subscribe(on: queue) + .sinkUntilComplete( + // We want to complete this job regardless of success or failure + receiveCompletion: { _ in success(job, false, dependencies) } + ) } public static func run(uploadOnlyIfStale: Bool) { @@ -130,44 +170,3 @@ extension SyncPushTokensJob { 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() - } -} diff --git a/Session/Notifications/UserNotificationsAdaptee.swift b/Session/Notifications/UserNotificationsAdaptee.swift index 3de1be30a..15c14a53a 100644 --- a/Session/Notifications/UserNotificationsAdaptee.swift +++ b/Session/Notifications/UserNotificationsAdaptee.swift @@ -1,13 +1,11 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import UserNotifications -import PromiseKit +import SessionMessagingKit import SignalCoreKit import SignalUtilitiesKit -import SessionMessagingKit class UserNotificationConfig { @@ -21,41 +19,49 @@ class UserNotificationConfig { } class func notificationCategory(_ category: AppNotificationCategory) -> UNNotificationCategory { - return UNNotificationCategory(identifier: category.identifier, - actions: notificationActions(for: category), - intentIdentifiers: [], - options: []) + return UNNotificationCategory( + identifier: category.identifier, + actions: notificationActions(for: category), + intentIdentifiers: [], + options: [] + ) } class func notificationAction(_ action: AppNotificationAction) -> UNNotificationAction { switch action { - case .markAsRead: - return UNNotificationAction(identifier: action.identifier, - title: MessageStrings.markAsReadNotificationAction, - options: []) - case .reply: - return UNTextInputNotificationAction(identifier: action.identifier, - title: MessageStrings.replyNotificationAction, - options: [], - textInputButtonTitle: MessageStrings.sendButton, - textInputPlaceholder: "") - case .showThread: - return UNNotificationAction(identifier: action.identifier, - title: CallStrings.showThreadButtonTitle, - options: [.foreground]) + case .markAsRead: + return UNNotificationAction( + identifier: action.identifier, + title: MessageStrings.markAsReadNotificationAction, + options: [] + ) + + case .reply: + return UNTextInputNotificationAction( + identifier: action.identifier, + title: MessageStrings.replyNotificationAction, + options: [], + textInputButtonTitle: MessageStrings.sendButton, + textInputPlaceholder: "" + ) + + case .showThread: + return UNNotificationAction( + identifier: action.identifier, + title: CallStrings.showThreadButtonTitle, + options: [.foreground] + ) } } class func action(identifier: String) -> AppNotificationAction? { return AppNotificationAction.allCases.first { notificationAction($0).identifier == identifier } } - } class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelegate { - private let notificationCenter: UNUserNotificationCenter - private var notifications: [String: UNNotificationRequest] = [:] + private var notifications: Atomic<[String: UNNotificationRequest]> = Atomic([:]) override init() { self.notificationCenter = UNUserNotificationCenter.current() @@ -67,26 +73,27 @@ class UserNotificationPresenterAdaptee: NSObject, UNUserNotificationCenterDelega } extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { - - func registerNotificationSettings() -> Promise { - return Promise { resolver in - notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in - self.notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories) - - if granted { + func registerNotificationSettings() -> AnyPublisher { + return Deferred { + Future { [weak self] resolver in + self?.notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in + self?.notificationCenter.setNotificationCategories(UserNotificationConfig.allNotificationCategories) - } else if error != nil { - Logger.error("failed with error: \(error!)") - } else { - Logger.error("failed without error.") + if granted {} + else if let error: Error = error { + Logger.error("failed with error: \(error)") + } + else { + Logger.error("failed without error.") + } + + // Note that the promise is fulfilled regardless of if notification permssions were + // granted. This promise only indicates that the user has responded, so we can + // proceed with requesting push tokens and complete registration. + resolver(Result.success(())) } - - // Note that the promise is fulfilled regardless of if notification permssions were - // granted. This promise only indicates that the user has responded, so we can - // proceed with requesting push tokens and complete registration. - resolver.fulfill(()) } - } + }.eraseToAnyPublisher() } func notify( @@ -98,10 +105,9 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { sound: Preferences.Sound?, threadVariant: SessionThread.Variant, threadName: String, + applicationState: UIApplication.State, replacingIdentifier: String? ) { - AssertIsOnMainThread() - let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String) let content = UNMutableNotificationContent() content.categoryIdentifier = category.identifier @@ -109,19 +115,24 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { content.threadIdentifier = (threadIdentifier ?? content.threadIdentifier) let shouldGroupNotification: Bool = ( - threadVariant == .openGroup && + threadVariant == .community && replacingIdentifier == threadIdentifier ) - let isAppActive = UIApplication.shared.applicationState == .active if let sound = sound, sound != .none { - content.sound = sound.notificationSound(isQuiet: isAppActive) + content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) } let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString) - let isReplacingNotification: Bool = (notifications[notificationIdentifier] != nil) + let isReplacingNotification: Bool = (notifications.wrappedValue[notificationIdentifier] != nil) + let shouldPresentNotification: Bool = shouldPresentNotification( + category: category, + applicationState: applicationState, + frontMostViewController: SessionApp.currentlyOpenConversationViewController.wrappedValue, + userInfo: userInfo + ) var trigger: UNNotificationTrigger? - if shouldPresentNotification(category: category, userInfo: userInfo) { + if shouldPresentNotification { if let displayableTitle = title?.filterForDisplay { content.title = displayableTitle } @@ -135,7 +146,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { repeats: false ) - let numberExistingNotifications: Int? = notifications[notificationIdentifier]? + let numberExistingNotifications: Int? = notifications.wrappedValue[notificationIdentifier]? .content .userInfo[AppNotificationUserInfoKey.threadNotificationCounter] .asType(Int.self) @@ -173,47 +184,48 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) } notificationCenter.add(request) - notifications[notificationIdentifier] = request + notifications.mutate { $0[notificationIdentifier] = request } } func cancelNotifications(identifiers: [String]) { - AssertIsOnMainThread() - identifiers.forEach { notifications.removeValue(forKey: $0) } + notifications.mutate { notifications in + identifiers.forEach { notifications.removeValue(forKey: $0) } + } notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) } func cancelNotification(_ notification: UNNotificationRequest) { - AssertIsOnMainThread() cancelNotifications(identifiers: [notification.identifier]) } func cancelNotifications(threadId: String) { - AssertIsOnMainThread() - for notification in notifications.values { - guard let notificationThreadId = notification.content.userInfo[AppNotificationUserInfoKey.threadId] as? String else { - continue + let notificationsIdsToCancel: [String] = notifications.wrappedValue + .values + .compactMap { notification in + guard + let notificationThreadId: String = notification.content.userInfo[AppNotificationUserInfoKey.threadId] as? String, + notificationThreadId == threadId + else { return nil } + + return notification.identifier } - - guard notificationThreadId == threadId else { - continue - } - - cancelNotification(notification) - } + + cancelNotifications(identifiers: notificationsIdsToCancel) } func clearAllNotifications() { - AssertIsOnMainThread() notificationCenter.removeAllPendingNotificationRequests() notificationCenter.removeAllDeliveredNotifications() } - func shouldPresentNotification(category: AppNotificationCategory, userInfo: [AnyHashable: Any]) -> Bool { - AssertIsOnMainThread() - guard UIApplication.shared.applicationState == .active else { - return true - } + func shouldPresentNotification( + category: AppNotificationCategory, + applicationState: UIApplication.State, + frontMostViewController: UIViewController?, + userInfo: [AnyHashable: Any] + ) -> Bool { + guard applicationState == .active else { return true } guard category == .incomingMessage || category == .errorMessage else { return true @@ -224,7 +236,7 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee { return true } - guard let conversationViewController = UIApplication.shared.frontmostViewController as? ConversationVC else { + guard let conversationViewController: ConversationVC = frontMostViewController as? ConversationVC else { return true } @@ -243,32 +255,43 @@ public class UserNotificationActionHandler: NSObject { @objc func handleNotificationResponse( _ response: UNNotificationResponse, completionHandler: @escaping () -> Void) { AssertIsOnMainThread() - firstly { - try handleNotificationResponse(response) - }.done { - completionHandler() - }.catch { error in - completionHandler() - owsFailDebug("error: \(error)") - Logger.error("error: \(error)") - }.retainUntilComplete() + handleNotificationResponse(response) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + completionHandler() + owsFailDebug("error: \(error)") + Logger.error("error: \(error)") + } + }, + receiveValue: { _ in completionHandler() } + ) } - func handleNotificationResponse( _ response: UNNotificationResponse) throws -> Promise { + func handleNotificationResponse( _ response: UNNotificationResponse) -> AnyPublisher { AssertIsOnMainThread() assert(AppReadiness.isAppReady()) - let userInfo = response.notification.request.content.userInfo + let userInfo: [AnyHashable: Any] = response.notification.request.content.userInfo + let applicationState: UIApplication.State = UIApplication.shared.applicationState switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: Logger.debug("default action") - return try actionHandler.showThread(userInfo: userInfo) + return actionHandler.showThread(userInfo: userInfo) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() case UNNotificationDismissActionIdentifier: // TODO - mark as read? Logger.debug("dismissed notification") - return Promise.value(()) + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() default: // proceed @@ -276,22 +299,26 @@ public class UserNotificationActionHandler: NSObject { } guard let action = UserNotificationConfig.action(identifier: response.actionIdentifier) else { - throw NotificationError.failDebug("unable to find action for actionIdentifier: \(response.actionIdentifier)") + return Fail(error: NotificationError.failDebug("unable to find action for actionIdentifier: \(response.actionIdentifier)")) + .eraseToAnyPublisher() } switch action { case .markAsRead: - return try actionHandler.markAsRead(userInfo: userInfo) + return actionHandler.markAsRead(userInfo: userInfo) case .reply: guard let textInputResponse = response as? UNTextInputNotificationResponse else { - throw NotificationError.failDebug("response had unexpected type: \(response)") + return Fail(error: NotificationError.failDebug("response had unexpected type: \(response)")) + .eraseToAnyPublisher() } - return try actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText) + return actionHandler.reply(userInfo: userInfo, replyText: textInputResponse.userText, applicationState: applicationState) case .showThread: - return try actionHandler.showThread(userInfo: userInfo) + return actionHandler.showThread(userInfo: userInfo) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } } } diff --git a/Session/Onboarding/DisplayNameVC.swift b/Session/Onboarding/DisplayNameVC.swift index aaf6a297b..051b45aa1 100644 --- a/Session/Onboarding/DisplayNameVC.swift +++ b/Session/Onboarding/DisplayNameVC.swift @@ -6,11 +6,25 @@ import SessionMessagingKit import SignalUtilitiesKit final class DisplayNameVC: BaseVC { + private let flow: Onboarding.Flow + private var spacer1HeightConstraint: NSLayoutConstraint! private var spacer2HeightConstraint: NSLayoutConstraint! private var registerButtonBottomOffsetConstraint: NSLayoutConstraint! private var bottomConstraint: NSLayoutConstraint! + // MARK: - Initialization + + init(flow: Onboarding.Flow) { + self.flow = flow + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + // MARK: - Components private lazy var displayNameTextField: TextField = { @@ -175,12 +189,23 @@ final class DisplayNameVC: BaseVC { // Try to save the user name but ignore the result ProfileManager.updateLocal( - queue: DispatchQueue.global(qos: .default), - profileName: displayName, - image: nil, - imageFilePath: nil + queue: .global(qos: .default), + profileName: displayName ) - let pnModeVC = PNModeVC() + + // If we are not in the registration flow then we are finished and should go straight + // to the home screen + guard self.flow == .register else { + self.flow.completeRegistration() + + // Go to the home screen + let homeVC: HomeVC = HomeVC() + self.navigationController?.setViewControllers([ homeVC ], animated: true) + return + } + + // Need to get the PN mode if registering + let pnModeVC = PNModeVC(flow: .register) navigationController?.pushViewController(pnModeVC, animated: true) } } diff --git a/Session/Onboarding/LinkDeviceVC.swift b/Session/Onboarding/LinkDeviceVC.swift index da5802576..bef5d2460 100644 --- a/Session/Onboarding/LinkDeviceVC.swift +++ b/Session/Onboarding/LinkDeviceVC.swift @@ -2,7 +2,6 @@ import UIKit import AVFoundation -import PromiseKit import SessionUIKit import SessionUtilitiesKit import SessionSnodeKit @@ -87,15 +86,17 @@ final class LinkDeviceVC: BaseVC, UIPageViewControllerDataSource, UIPageViewCont scanQRCodePlaceholderVC.constrainHeight(to: height) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + Onboarding.Flow.register.unregister() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) tabBarTopConstraint.constant = navigationController!.navigationBar.height() } - deinit { - NotificationCenter.default.removeObserver(self) - } - // MARK: - General func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { @@ -133,12 +134,12 @@ final class LinkDeviceVC: BaseVC, UIPageViewControllerDataSource, UIPageViewCont dismiss(animated: true, completion: nil) } - func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String) { + func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String, onError: (() -> ())?) { let seed = Data(hex: string) - continueWithSeed(seed) + continueWithSeed(seed, onError: onError) } - func continueWithSeed(_ seed: Data) { + func continueWithSeed(_ seed: Data, onError: (() -> ())?) { if (seed.count != 16) { let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( @@ -146,41 +147,24 @@ final class LinkDeviceVC: BaseVC, UIPageViewControllerDataSource, UIPageViewCont body: .text("INVALID_RECOVERY_PHRASE_MESSAGE".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text, - afterClosed: { [weak self] in - self?.scanQRCodeWrapperVC.startCapture() - } + afterClosed: onError ) ) present(modal, animated: true) return } let (ed25519KeyPair, x25519KeyPair) = try! Identity.generate(from: seed) - Onboarding.Flow.link.preregister(with: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) - Identity.didRegister() + Onboarding.Flow.link + .preregister( + with: seed, + ed25519KeyPair: ed25519KeyPair, + x25519KeyPair: x25519KeyPair + ) - // Now that we have registered get the Snode pool - GetSnodePoolJob.run() - - NotificationCenter.default.addObserver(self, selector: #selector(handleInitialConfigurationMessageReceived), name: .initialConfigurationMessageReceived, object: nil) - - ModalActivityIndicatorViewController - .present( - // There was some crashing here due to force-unwrapping so just falling back to - // using self if there is no nav controller - fromViewController: (self.navigationController ?? self) - ) { [weak self] modal in - self?.activityIndicatorModal = modal - } - } - - @objc private func handleInitialConfigurationMessageReceived(_ notification: Notification) { - DispatchQueue.main.async { - self.navigationController!.dismiss(animated: true) { - let pnModeVC = PNModeVC() - self.navigationController!.setViewControllers([ pnModeVC ], animated: true) - } - } + // Otherwise continue on to request push notifications permissions + let pnModeVC: PNModeVC = PNModeVC(flow: .link) + self.navigationController?.pushViewController(pnModeVC, animated: true) } } @@ -333,7 +317,7 @@ private final class RecoveryPhraseVC: UIViewController { let hexEncodedSeed = try Mnemonic.decode(mnemonic: mnemonic) let seed = Data(hex: hexEncodedSeed) mnemonicTextView.resignFirstResponder() - linkDeviceVC.continueWithSeed(seed) + linkDeviceVC.continueWithSeed(seed, onError: nil) } catch let error { let error = error as? Mnemonic.DecodingError ?? Mnemonic.DecodingError.generic showError(title: error.errorDescription!) diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index 3814a223c..97607724d 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -1,56 +1,262 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import Sodium import GRDB -import Curve25519Kit import SessionUtilitiesKit import SessionMessagingKit +import SessionSnodeKit enum Onboarding { + private static let profileNameRetrievalIdentifier: Atomic = Atomic(nil) + private static let profileNameRetrievalPublisher: Atomic?> = Atomic(nil) + public static var profileNamePublisher: AnyPublisher { + guard let existingPublisher: AnyPublisher = profileNameRetrievalPublisher.wrappedValue else { + return profileNameRetrievalPublisher.mutate { value in + let requestId: UUID = UUID() + let result: AnyPublisher = createProfileNameRetrievalPublisher(requestId) + + value = result + profileNameRetrievalIdentifier.mutate { $0 = requestId } + return result + } + } + + return existingPublisher + } + + private static func createProfileNameRetrievalPublisher(_ requestId: UUID) -> AnyPublisher { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled else { + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + let userPublicKey: String = getUserHexEncodedPublicKey() + + return SnodeAPI.getSwarm(for: userPublicKey) + .tryFlatMapWithRandomSnode { snode -> AnyPublisher in + CurrentUserPoller + .poll( + namespaces: [.configUserProfile], + from: snode, + for: userPublicKey, + // Note: These values mean the received messages will be + // processed immediately rather than async as part of a Job + calledFromBackgroundPoller: true, + isBackgroundPollValid: { true } + ) + .tryFlatMap { receivedMessageTypes -> AnyPublisher in + // FIXME: Remove this entire 'tryFlatMap' once the updated user config has been released for long enough + guard + receivedMessageTypes.isEmpty, + requestId == profileNameRetrievalIdentifier.wrappedValue + else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + SNLog("Onboarding failed to retrieve user config, checking for legacy config") + + return CurrentUserPoller + .poll( + namespaces: [.default], + from: snode, + for: userPublicKey, + // Note: These values mean the received messages will be + // processed immediately rather than async as part of a Job + calledFromBackgroundPoller: true, + isBackgroundPollValid: { true } + ) + .tryMap { receivedMessageTypes -> Void in + guard + let message: ConfigurationMessage = receivedMessageTypes + .last(where: { $0 is ConfigurationMessage }) + .asType(ConfigurationMessage.self), + let displayName: String = message.displayName, + requestId == profileNameRetrievalIdentifier.wrappedValue + else { return () } + + // Handle user profile changes + Storage.shared.write { db in + try ProfileManager.updateProfileIfNeeded( + db, + publicKey: userPublicKey, + name: displayName, + avatarUpdate: { + guard + let profilePictureUrl: String = message.profilePictureUrl, + let profileKey: Data = message.profileKey + else { return .none } + + return .updateTo( + url: profilePictureUrl, + key: profileKey, + fileName: nil + ) + }(), + sentTimestamp: TimeInterval((message.sentTimestamp ?? 0) / 1000), + calledFromConfigHandling: false + ) + } + return () + } + .eraseToAnyPublisher() + } + } + .map { _ -> String? in + guard requestId == profileNameRetrievalIdentifier.wrappedValue else { + return nil + } + + return Storage.shared.read { db in + try Profile + .filter(id: userPublicKey) + .select(.name) + .asRequest(of: String.self) + .fetchOne(db) + } + } + .shareReplay(1) + .eraseToAnyPublisher() + } + + enum State { + case newUser + case missingName + case completed + + static var current: State { + // If we have no identify information then the user needs to register + guard Identity.userExists() else { return .newUser } + + // If we have no display name then collect one (this can happen if the + // app crashed during onboarding which would leave the user in an invalid + // state with no display name) + guard !Profile.fetchOrCreateCurrentUser().name.isEmpty else { return .missingName } + + // Otherwise we have enough for a full user and can start the app + return .completed + } + } enum Flow { case register, recover, link - func preregister(with seed: Data, ed25519KeyPair: Sign.KeyPair, x25519KeyPair: ECKeyPair) { - let userDefaults = UserDefaults.standard - Identity.store(seed: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) + /// If the user returns to an earlier screen during Onboarding we might need to clear out a partially created + /// account (eg. returning from the PN setting screen to the seed entry screen when linking a device) + func unregister() { + // Clear the in-memory state from SessionUtil + SessionUtil.clearMemoryState() + + // Clear any data which gets set during Onboarding + Storage.shared.write { db in + db[.hasViewedSeed] = false + + try SessionThread.deleteAll(db) + try Profile.deleteAll(db) + try Contact.deleteAll(db) + try Identity.deleteAll(db) + try ConfigDump.deleteAll(db) + try SnodeReceivedMessageInfo.deleteAll(db) + } + + // Clear the profile name retrieve publisher + profileNameRetrievalIdentifier.mutate { $0 = nil } + profileNameRetrievalPublisher.mutate { $0 = nil } + + UserDefaults.standard[.hasSyncedInitialConfiguration] = false + } + + func preregister(with seed: Data, ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) { let x25519PublicKey = x25519KeyPair.hexEncodedPublicKey + // Create the initial shared util state (won't have been created on + // launch due to lack of ed25519 key) + SessionUtil.loadState( + userPublicKey: x25519PublicKey, + ed25519SecretKey: ed25519KeyPair.secretKey + ) + + // Store the user identity information Storage.shared.write { db in - try Contact(id: x25519PublicKey) - .with( - isApproved: true, - didApproveMe: true - ) + try Identity.store( + db, + seed: seed, + ed25519KeyPair: ed25519KeyPair, + x25519KeyPair: x25519KeyPair + ) + + // No need to show the seed again if the user is restoring or linking + db[.hasViewedSeed] = (self == .recover || self == .link) + + // Create a contact for the current user and set their approval/trusted statuses so + // they don't get weird behaviours + try Contact + .fetchOrCreate(db, id: x25519PublicKey) .save(db) + try Contact + .filter(id: x25519PublicKey) + .updateAllAndConfig( + db, + Contact.Columns.isTrusted.set(to: true), // Always trust the current user + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe.set(to: true) + ) + + /// Create the 'Note to Self' thread (not visible by default) + /// + /// **Note:** We need to explicitly `updateAllAndConfig` the `shouldBeVisible` value to `false` + /// otherwise it won't actually get synced correctly + try SessionThread + .fetchOrCreate(db, id: x25519PublicKey, variant: .contact, shouldBeVisible: false) + + try SessionThread + .filter(id: x25519PublicKey) + .updateAllAndConfig( + db, + SessionThread.Columns.shouldBeVisible.set(to: false) + ) } - switch self { - 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 + // 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.standard[.hasSyncedInitialConfiguration] = (self == .register) + + // Only continue if this isn't a new account + guard self != .register else { return } + + // Fetch the + Onboarding.profileNamePublisher + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() + } + + func completeRegistration() { + // Set the `lastNameUpdate` 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 (otherwise the user could enter a display name and have it immediately + // overwritten due to the config request running slow) + Storage.shared.write { db in + try Profile + .filter(id: getUserHexEncodedPublicKey(db)) + .updateAllAndConfig( + db, + Profile.Columns.lastNameUpdate.set(to: Date().timeIntervalSince1970) + ) } - 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 - } + // Notify the app that registration is complete + Identity.didRegister() + + // Now that we have registered get the Snode pool and sync push tokens + GetSnodePoolJob.run() + SyncPushTokensJob.run(uploadOnlyIfStale: false) } } } diff --git a/Session/Onboarding/PNModeVC.swift b/Session/Onboarding/PNModeVC.swift index 54f1b0e48..bf4884a29 100644 --- a/Session/Onboarding/PNModeVC.swift +++ b/Session/Onboarding/PNModeVC.swift @@ -1,14 +1,15 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import PromiseKit +import Combine import SessionUIKit import SessionMessagingKit import SessionSnodeKit import SignalUtilitiesKit final class PNModeVC: BaseVC, OptionViewDelegate { - + private let flow: Onboarding.Flow + private var optionViews: [OptionView] { [ apnsOptionView, backgroundPollingOptionView ] } @@ -16,7 +17,19 @@ final class PNModeVC: BaseVC, OptionViewDelegate { private var selectedOptionView: OptionView? { return optionViews.first { $0.isSelected } } - + + // MARK: - Initialization + + init(flow: Onboarding.Flow) { + self.flow = flow + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + // MARK: - Components private lazy var apnsOptionView: OptionView = { @@ -129,14 +142,69 @@ final class PNModeVC: BaseVC, OptionViewDelegate { } UserDefaults.standard[.isUsingFullAPNs] = (selectedOptionView == apnsOptionView) - Identity.didRegister() + // If we are registering then we can just continue on + guard flow != .register else { + self.flow.completeRegistration() + + // Go to the home screen + let homeVC: HomeVC = HomeVC() + self.navigationController?.setViewControllers([ homeVC ], animated: true) + return + } - // Go to the home screen - let homeVC: HomeVC = HomeVC() - self.navigationController?.setViewControllers([ homeVC ], animated: true) + // Check if we already have a profile name (ie. profile retrieval completed while waiting on + // this screen) + let existingProfileName: String? = Storage.shared + .read { db in + try Profile + .filter(id: getUserHexEncodedPublicKey(db)) + .select(.name) + .asRequest(of: String.self) + .fetchOne(db) + } - // Now that we have registered get the Snode pool and sync push tokens - GetSnodePoolJob.run() - SyncPushTokensJob.run(uploadOnlyIfStale: false) + guard existingProfileName?.isEmpty != false else { + // If we have one then we can go straight to the home screen + self.flow.completeRegistration() + + // Go to the home screen + let homeVC: HomeVC = HomeVC() + self.navigationController?.setViewControllers([ homeVC ], animated: true) + return + } + + // If we don't have one then show a loading indicator and try to retrieve the existing name + ModalActivityIndicatorViewController.present(fromViewController: self) { [weak self, flow = self.flow] viewController in + Onboarding.profileNamePublisher + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .timeout(.seconds(15), scheduler: DispatchQueue.main, customError: { HTTPError.timeout }) + .catch { _ -> AnyPublisher in + SNLog("Onboarding failed to retrieve existing profile information") + return Just(nil) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveValue: { value in + // Hide the loading indicator + viewController.dismiss(animated: true) + + // If we have no display name we need to collect one + guard value?.isEmpty == false else { + let displayNameVC: DisplayNameVC = DisplayNameVC(flow: flow) + self?.navigationController?.pushViewController(displayNameVC, animated: true) + return + } + + // Otherwise we are done and can go to the home screen + self?.flow.completeRegistration() + + // Go to the home screen + let homeVC: HomeVC = HomeVC() + self?.navigationController?.setViewControllers([ homeVC ], animated: true) + } + ) + } } } diff --git a/Session/Onboarding/RegisterVC.swift b/Session/Onboarding/RegisterVC.swift index 85ee23557..52cc441a6 100644 --- a/Session/Onboarding/RegisterVC.swift +++ b/Session/Onboarding/RegisterVC.swift @@ -2,14 +2,13 @@ import UIKit import Sodium -import Curve25519Kit import SessionUIKit import SignalUtilitiesKit final class RegisterVC : BaseVC { private var seed: Data! { didSet { updateKeyPair() } } - private var ed25519KeyPair: Sign.KeyPair! - private var x25519KeyPair: ECKeyPair! { didSet { updatePublicKeyLabel() } } + private var ed25519KeyPair: KeyPair! + private var x25519KeyPair: KeyPair! { didSet { updatePublicKeyLabel() } } // MARK: - Components @@ -152,6 +151,12 @@ final class RegisterVC : BaseVC { updateSeed() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + Onboarding.Flow.register.unregister() + } + // MARK: General @objc private func enableCopyButton() { copyPublicKeyButton.isUserInteractionEnabled = true @@ -161,8 +166,9 @@ final class RegisterVC : BaseVC { } // MARK: Updating + private func updateSeed() { - seed = Data.getSecureRandomData(ofSize: 16)! + seed = try! Randomness.generateRandomBytes(numberBytes: 16) } private func updateKeyPair() { @@ -199,11 +205,18 @@ final class RegisterVC : BaseVC { animate() } - // MARK: Interaction + // MARK: - Interaction + @objc private func register() { - Onboarding.Flow.register.preregister(with: seed, ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) - let displayNameVC = DisplayNameVC() - navigationController!.pushViewController(displayNameVC, animated: true) + Onboarding.Flow.register + .preregister( + with: seed, + ed25519KeyPair: ed25519KeyPair, + x25519KeyPair: x25519KeyPair + ) + + let displayNameVC: DisplayNameVC = DisplayNameVC(flow: .register) + self.navigationController?.pushViewController(displayNameVC, animated: true) } @objc private func copyPublicKey() { diff --git a/Session/Onboarding/RestoreVC.swift b/Session/Onboarding/RestoreVC.swift index 92f42d82d..e196c5f06 100644 --- a/Session/Onboarding/RestoreVC.swift +++ b/Session/Onboarding/RestoreVC.swift @@ -128,8 +128,15 @@ final class RestoreVC: BaseVC { notificationCenter.addObserver(self, selector: #selector(handleKeyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + Onboarding.Flow.register.unregister() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + // On small screens we hide the legal label when the keyboard is up, but it's important that the user sees it so // in those instances we don't make the keyboard come up automatically if !isIPhone5OrSmaller { @@ -194,22 +201,33 @@ final class RestoreVC: BaseVC { present(modal, animated: true) } - let mnemonic = mnemonicTextView.text!.lowercased() + let seed: Data + let keyPairs: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) + do { - let hexEncodedSeed = try Mnemonic.decode(mnemonic: mnemonic) - let seed = Data(hex: hexEncodedSeed) - 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 - let displayNameVC = DisplayNameVC() - self.navigationController!.pushViewController(displayNameVC, animated: true) - } - } catch let error { + let mnemonic: String = mnemonicTextView.text!.lowercased() + let hexEncodedSeed: String = try Mnemonic.decode(mnemonic: mnemonic) + seed = Data(hex: hexEncodedSeed) + keyPairs = try Identity.generate(from: seed) + } + catch let error { let error = error as? Mnemonic.DecodingError ?? Mnemonic.DecodingError.generic showError(title: error.errorDescription!) + return } + + // Load in the user config and progress to the next screen + mnemonicTextView.resignFirstResponder() + + Onboarding.Flow.recover + .preregister( + with: seed, + ed25519KeyPair: keyPairs.ed25519KeyPair, + x25519KeyPair: keyPairs.x25519KeyPair + ) + + let pnModeVC: PNModeVC = PNModeVC(flow: .recover) + self.navigationController?.pushViewController(pnModeVC, animated: true) } @objc private func handleLegalLabelTapped(_ tapGestureRecognizer: UITapGestureRecognizer) { diff --git a/Session/Onboarding/SeedReminderView.swift b/Session/Onboarding/SeedReminderView.swift index 83b0034c0..5570cc578 100644 --- a/Session/Onboarding/SeedReminderView.swift +++ b/Session/Onboarding/SeedReminderView.swift @@ -82,10 +82,11 @@ final class SeedReminderView: UIView { // Set up button let button = SessionButton(style: .bordered, size: .small) + button.setContentCompressionResistancePriority(.required, for: .horizontal) button.accessibilityLabel = "Continue" button.isAccessibilityElement = true button.setTitle("continue_2".localized(), for: UIControl.State.normal) - button.set(.width, to: 96) + button.set(.width, greaterThanOrEqualTo: 96) button.addTarget(self, action: #selector(handleContinueButtonTapped), for: UIControl.Event.touchUpInside) // Set up content stack view diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 7f0e0386b..b9983fa8c 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import AVFoundation import GRDB import SessionUIKit @@ -143,77 +144,104 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC dismiss(animated: true, completion: nil) } - func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String) { - joinOpenGroup(with: string) + func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String, onError: (() -> ())?) { + joinOpenGroup(with: string, onError: onError) } - fileprivate func joinOpenGroup(with urlString: String) { + fileprivate func joinOpenGroup(with urlString: String, onError: (() -> ())?) { // A V2 open group URL will look like: + + + + // The host doesn't parse if no explicit scheme is provided - guard let (room, server, publicKey) = OpenGroupManager.parseOpenGroup(from: urlString) else { + guard let (room, server, publicKey) = SessionUtil.parseCommunity(url: urlString) else { showError( title: "invalid_url".localized(), - message: "COMMUNITY_ERROR_INVALID_URL".localized() + message: "COMMUNITY_ERROR_INVALID_URL".localized(), + onError: onError ) return } - joinOpenGroup(roomToken: room, server: server, publicKey: publicKey) + joinOpenGroup(roomToken: room, server: server, publicKey: publicKey, shouldOpenCommunity: true, onError: onError) } - fileprivate func joinOpenGroup(roomToken: String, server: String, publicKey: String, shouldOpenCommunity: Bool = false) { + fileprivate func joinOpenGroup(roomToken: String, server: String, publicKey: String, shouldOpenCommunity: Bool, onError: (() -> ())?) { guard !isJoining, let navigationController: UINavigationController = navigationController else { return } isJoining = true ModalActivityIndicatorViewController.present(fromViewController: navigationController, canCancel: false) { [weak self] _ in Storage.shared - .writeAsync { db in + .writePublisher { db in OpenGroupManager.shared.add( db, roomToken: roomToken, server: server, publicKey: publicKey, - isConfigMessage: false + calledFromConfigHandling: 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) - - if shouldOpenCommunity { - SessionApp.presentConversation( - for: OpenGroup.idFor(roomToken: roomToken, server: server), - threadVariant: .openGroup, - isMessageRequest: false, - action: .compose, - focusInteractionId: nil, - animated: false - ) - } - } - .catch(on: DispatchQueue.main) { [weak self] error in - self?.dismiss(animated: true, completion: nil) // Dismiss the loader - let title = "COMMUNITY_ERROR_GENERIC".localized() - let message = error.localizedDescription - self?.isJoining = false - self?.showError(title: title, message: message) + .flatMap { successfullyAddedGroup in + OpenGroupManager.shared.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: roomToken, + server: server, + publicKey: publicKey, + calledFromConfigHandling: false + ) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .failure(let error): + // If there was a failure then the group will be in invalid state until + // the next launch so remove it (the user will be left on the previous + // screen so can re-trigger the join) + Storage.shared.writeAsync { db in + OpenGroupManager.shared.delete( + db, + openGroupId: OpenGroup.idFor(roomToken: roomToken, server: server), + calledFromConfigHandling: false + ) + } + + // Show the user an error indicating they failed to properly join the group + self?.isJoining = false + self?.dismiss(animated: true) { // Dismiss the loader + self?.showError( + title: "COMMUNITY_ERROR_GENERIC".localized(), + message: error.localizedDescription, + onError: onError + ) + } + + case .finished: + self?.presentingViewController?.dismiss(animated: true, completion: nil) + + if shouldOpenCommunity { + SessionApp.presentConversationCreatingIfNeeded( + for: OpenGroup.idFor(roomToken: roomToken, server: server), + variant: .community, + dismissing: nil, + animated: false + ) + } + } + } + ) } } // MARK: - Convenience - private func showError(title: String, message: String = "") { + private func showError(title: String, message: String = "", onError: (() -> ())?) { let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: title, body: .text(message), cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text + cancelStyle: .alert_text, + afterClosed: onError ) ) self.navigationController?.present(confirmationModal, animated: true, completion: nil) @@ -374,13 +402,14 @@ private final class EnterURLVC: UIViewController, UIGestureRecognizerDelegate, O roomToken: room.token, server: OpenGroupAPI.defaultServer, publicKey: OpenGroupAPI.defaultServerPublicKey, - shouldOpenCommunity: true + shouldOpenCommunity: true, + onError: nil ) } @objc private func joinOpenGroup() { let url = urlTextView.text?.trimmingCharacters(in: .whitespaces) ?? "" - joinOpenGroupVC?.joinOpenGroup(with: url) + joinOpenGroupVC?.joinOpenGroup(with: url, onError: nil) } // MARK: - Updating diff --git a/Session/Open Groups/OpenGroupSuggestionGrid.swift b/Session/Open Groups/OpenGroupSuggestionGrid.swift index 9ee5c5b67..f7037bc55 100644 --- a/Session/Open Groups/OpenGroupSuggestionGrid.swift +++ b/Session/Open Groups/OpenGroupSuggestionGrid.swift @@ -1,4 +1,7 @@ -import PromiseKit +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine import NVActivityIndicatorView import SessionMessagingKit import SessionUIKit @@ -6,7 +9,7 @@ import SessionUIKit final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { private let itemsPerSection: Int = (UIDevice.current.isIPad ? 4 : 2) private var maxWidth: CGFloat - private var rooms: [OpenGroupAPI.Room] = [] { didSet { update() } } + private var data: [OpenGroupManager.DefaultRoomInfo] = [] { didSet { update() } } private var heightConstraint: NSLayoutConstraint! var delegate: OpenGroupSuggestionGridDelegate? @@ -15,7 +18,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle private static let cellHeight: CGFloat = 40 private static let separatorWidth = Values.separatorThickness - private static let numHorizontalCells: CGFloat = (UIDevice.current.isIPad ? 4 : 2) + fileprivate static let numHorizontalCells: Int = (UIDevice.current.isIPad ? 4 : 2) private lazy var layout: LastRowCenteredLayout = { let result = LastRowCenteredLayout() @@ -140,12 +143,17 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true OpenGroupManager.getDefaultRoomsIfNeeded() - .done { [weak self] rooms in - self?.rooms = rooms - } - .catch { [weak self] _ in - self?.update() - } + .subscribe(on: DispatchQueue.global(qos: .default)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure: self?.update() + } + }, + receiveValue: { [weak self] roomInfo in self?.data = roomInfo } + ) } // MARK: - Updating @@ -154,8 +162,8 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle spinner.stopAnimating() spinner.isHidden = true - let roomCount: CGFloat = CGFloat(min(rooms.count, 8)) // Cap to a maximum of 8 (4 rows of 2) - let numRows: CGFloat = ceil(roomCount / OpenGroupSuggestionGrid.numHorizontalCells) + let roomCount: CGFloat = CGFloat(min(data.count, 8)) // Cap to a maximum of 8 (4 rows of 2) + let numRows: CGFloat = ceil(roomCount / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells)) let height: CGFloat = ((OpenGroupSuggestionGrid.cellHeight * numRows) + ((numRows - 1) * layout.minimumLineSpacing)) heightConstraint.constant = height collectionView.reloadData() @@ -170,18 +178,18 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // MARK: - Layout func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - guard - indexPath.item == (collectionView.numberOfItems(inSection: indexPath.section) - 1) && - indexPath.item % 2 == 0 - else { - let cellWidth: CGFloat = ((maxWidth / OpenGroupSuggestionGrid.numHorizontalCells) - ((OpenGroupSuggestionGrid.numHorizontalCells - 1) * layout.minimumInteritemSpacing)) + let totalItems: Int = collectionView.numberOfItems(inSection: indexPath.section) + let itemsInFinalRow: Int = (totalItems % OpenGroupSuggestionGrid.numHorizontalCells) + + guard indexPath.item >= (totalItems - itemsInFinalRow) && itemsInFinalRow != 0 else { + let cellWidth: CGFloat = ((maxWidth / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells)) - ((CGFloat(OpenGroupSuggestionGrid.numHorizontalCells) - 1) * layout.minimumInteritemSpacing)) return CGSize(width: cellWidth, height: OpenGroupSuggestionGrid.cellHeight) } - // If the last item is by itself then we want to make it wider + // If there isn't an even number of items then we want to calculate proper sizing return CGSize( - width: (Cell.calculatedWith(for: rooms[indexPath.item].name)), + width: Cell.calculatedWith(for: data[indexPath.item].room.name), height: OpenGroupSuggestionGrid.cellHeight ) } @@ -189,12 +197,12 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // 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) + return min(data.count, 8) // Cap to a maximum of 8 (4 rows of 2) } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath) - cell.room = rooms[indexPath.item] + cell.update(with: data[indexPath.item].room, existingImageData: data[indexPath.item].existingImageData) return cell } @@ -202,7 +210,7 @@ final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UIColle // MARK: - Interaction func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - let room = rooms[indexPath.section * itemsPerSection + indexPath.item] + let room = data[indexPath.section * itemsPerSection + indexPath.item].room delegate?.join(room) } } @@ -229,8 +237,6 @@ extension OpenGroupSuggestionGrid { ) } - var room: OpenGroupAPI.Room? { didSet { update() } } - private lazy var snContentView: UIView = { let result: UIView = UIView() result.themeBorderColor = .borderSeparator @@ -304,9 +310,7 @@ extension OpenGroupSuggestionGrid { snContentView.pin(to: self) } - private func update() { - guard let room: OpenGroupAPI.Room = room else { return } - + fileprivate func update(with room: OpenGroupAPI.Room, existingImageData: Data?) { label.text = room.name // Only continue if we have a room image @@ -315,24 +319,44 @@ extension OpenGroupSuggestionGrid { return } - let promise = Storage.shared.read { db in - OpenGroupManager.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer) - } + imageView.image = nil - if let imageData: Data = promise.value { - imageView.image = UIImage(data: imageData) - imageView.isHidden = (imageView.image == nil) - } - else { - imageView.isHidden = true - - _ = promise.done { [weak self] imageData in - DispatchQueue.main.async { + Publishers + .MergeMany( + OpenGroupManager + .roomImage( + fileId: imageId, + for: room.token, + on: OpenGroupAPI.defaultServer, + existingData: existingImageData + ) + .map { ($0, true) } + .eraseToAnyPublisher(), + // If we have already received the room image then the above will emit first and + // we can ignore this 'Just' call which is used to hide the image while loading + Just((Data(), false)) + .setFailureType(to: Error.self) + .delay(for: .milliseconds(10), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + ) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveValue: { [weak self] imageData, hasData in + guard hasData else { + // This will emit twice (once with the data and once without it), if we + // have actually received the images then we don't want the second emission + // to hide the imageView anymore + if self?.imageView.image == nil { + self?.imageView.isHidden = true + } + return + } + self?.imageView.image = UIImage(data: imageData) self?.imageView.isHidden = (self?.imageView.image == nil) } - } - } + ) } } } @@ -361,16 +385,30 @@ class LastRowCenteredLayout: UICollectionViewFlowLayout { }() guard - (elementAttributes?.count ?? 0) % 2 == 1, - let lastItemAttributes: UICollectionViewLayoutAttributes = elementAttributes?.last + let remainingItems: Int = elementAttributes.map({ $0.count % OpenGroupSuggestionGrid.numHorizontalCells }), + remainingItems != 0, + let lastItems: [UICollectionViewLayoutAttributes] = elementAttributes?.suffix(remainingItems), + !lastItems.isEmpty else { return elementAttributes } - lastItemAttributes.frame = CGRect( - x: ((targetViewWidth - lastItemAttributes.frame.size.width) / 2), - y: lastItemAttributes.frame.origin.y, - width: lastItemAttributes.frame.size.width, - height: lastItemAttributes.frame.size.height - ) + let totalItemWidth: CGFloat = lastItems + .map { $0.frame.size.width } + .reduce(0, +) + let lastRowWidth: CGFloat = (totalItemWidth + (CGFloat(lastItems.count - 1) * minimumInteritemSpacing)) + + // Offset the start width by half of the remaining space + var itemXPos: CGFloat = ((targetViewWidth - lastRowWidth) / 2) + + lastItems.forEach { item in + item.frame = CGRect( + x: itemXPos, + y: item.frame.origin.y, + width: item.frame.size.width, + height: item.frame.size.height + ) + + itemXPos += (item.frame.size.width + minimumInteritemSpacing) + } return elementAttributes } diff --git a/Session/Path/PathStatusView.swift b/Session/Path/PathStatusView.swift index c606b3268..a0fdb805d 100644 --- a/Session/Path/PathStatusView.swift +++ b/Session/Path/PathStatusView.swift @@ -4,6 +4,7 @@ import UIKit import Reachability import SessionUIKit import SessionSnodeKit +import SessionMessagingKit final class PathStatusView: UIView { enum Size { @@ -44,7 +45,7 @@ final class PathStatusView: UIView { // MARK: - Initialization private let size: Size - private let reachability: Reachability = Reachability.forInternetConnection() + private let reachability: Reachability? = Environment.shared?.reachabilityManager.reachability init(size: Size = .small) { self.size = size @@ -76,10 +77,10 @@ final class PathStatusView: UIView { self.set(.width, to: self.size.pointSize) self.set(.height, to: self.size.pointSize) - switch (reachability.isReachable(), OnionRequestAPI.paths.isEmpty) { - case (false, _): setStatus(to: .error) - case (true, true): setStatus(to: .connecting) - case (true, false): setStatus(to: .connected) + switch (reachability?.isReachable(), OnionRequestAPI.paths.isEmpty) { + case (.some(false), _), (nil, _): setStatus(to: .error) + case (.some(true), true): setStatus(to: .connecting) + case (.some(true), false): setStatus(to: .connected) } } @@ -124,7 +125,7 @@ final class PathStatusView: UIView { } @objc private func handleBuildingPathsNotification() { - guard reachability.isReachable() else { + guard reachability?.isReachable() == true else { setStatus(to: .error) return } @@ -133,7 +134,7 @@ final class PathStatusView: UIView { } @objc private func handlePathsBuiltNotification() { - guard reachability.isReachable() else { + guard reachability?.isReachable() == true else { setStatus(to: .error) return } @@ -147,7 +148,7 @@ final class PathStatusView: UIView { return } - guard reachability.isReachable() else { + guard reachability?.isReachable() == true else { setStatus(to: .error) return } diff --git a/Session/Path/PathVC.swift b/Session/Path/PathVC.swift index 6004e6ed9..144fe2171 100644 --- a/Session/Path/PathVC.swift +++ b/Session/Path/PathVC.swift @@ -253,7 +253,7 @@ private final class LineView: UIView { private var dotViewWidthConstraint: NSLayoutConstraint! private var dotViewHeightConstraint: NSLayoutConstraint! private var dotViewAnimationTimer: Timer! - private let reachability: Reachability = Reachability.forInternetConnection() + private let reachability: Reachability? = Environment.shared?.reachabilityManager.reachability enum Location { case top, middle, bottom @@ -338,10 +338,10 @@ private final class LineView: UIView { } } - switch (reachability.isReachable(), OnionRequestAPI.paths.isEmpty) { - case (false, _): setStatus(to: .error) - case (true, true): setStatus(to: .connecting) - case (true, false): setStatus(to: .connected) + switch (reachability?.isReachable(), OnionRequestAPI.paths.isEmpty) { + case (.some(false), _), (nil, _): setStatus(to: .error) + case (.some(true), true): setStatus(to: .connecting) + case (.some(true), false): setStatus(to: .connected) } } @@ -392,7 +392,7 @@ private final class LineView: UIView { } @objc private func handleBuildingPathsNotification() { - guard reachability.isReachable() else { + guard reachability?.isReachable() == true else { setStatus(to: .error) return } @@ -401,7 +401,7 @@ private final class LineView: UIView { } @objc private func handlePathsBuiltNotification() { - guard reachability.isReachable() else { + guard reachability?.isReachable() == true else { setStatus(to: .error) return } @@ -415,7 +415,7 @@ private final class LineView: UIView { return } - guard reachability.isReachable() else { + guard reachability?.isReachable() == true else { setStatus(to: .error) return } diff --git a/Session/Settings/BlockedContactsViewController.swift b/Session/Settings/BlockedContactsViewController.swift deleted file mode 100644 index cc6cfb85d..000000000 --- a/Session/Settings/BlockedContactsViewController.swift +++ /dev/null @@ -1,518 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import GRDB -import DifferenceKit -import SessionUIKit -import SessionMessagingKit -import SignalUtilitiesKit - -class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { - private static let loadingHeaderHeight: CGFloat = 40 - - private let viewModel: BlockedContactsViewModel = BlockedContactsViewModel() - private var dataChangeObservable: DatabaseCancellable? - private var hasLoadedInitialContactData: 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 lazy var tableView: UITableView = { - let result: UITableView = UITableView() - result.translatesAutoresizingMaskIntoConstraints = false - result.clipsToBounds = true - result.separatorStyle = .none - result.themeBackgroundColor = .clear - result.showsVerticalScrollIndicator = false - result.register(view: SessionCell.self) - result.dataSource = self - result.delegate = self - result.layer.cornerRadius = SessionCell.cornerRadius - - if #available(iOS 15.0, *) { - result.sectionHeaderTopPadding = 0 - } - - return result - }() - - private lazy var emptyStateLabel: UILabel = { - let result: UILabel = UILabel() - result.translatesAutoresizingMaskIntoConstraints = false - result.isUserInteractionEnabled = false - result.font = .systemFont(ofSize: Values.smallFontSize) - result.text = "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized() - result.themeTextColor = .textSecondary - result.textAlignment = .center - result.numberOfLines = 0 - result.isHidden = true - - return result - }() - - private lazy var fadeView: GradientView = { - let result: GradientView = GradientView() - result.themeBackgroundGradient = [ - .value(.backgroundPrimary, alpha: 0), // Want this to take up 20% (~25pt) - .backgroundPrimary, - .backgroundPrimary, - .backgroundPrimary, - .backgroundPrimary - ] - result.set(.height, to: Values.footerGradientHeight(window: UIApplication.shared.keyWindow)) - - return result - }() - - private lazy var unblockButton: SessionButton = { - let result: SessionButton = SessionButton(style: .destructive, size: .large) - result.translatesAutoresizingMaskIntoConstraints = false - result.setTitle("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK".localized(), for: .normal) - result.addTarget(self, action: #selector(unblockTapped), for: .touchUpInside) - - return result - }() - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - view.themeBackgroundColor = .backgroundPrimary - - ViewControllerUtilities.setUpDefaultSessionStyle( - for: self, - title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized(), - hasCustomBackButton: false - ) - - view.addSubview(tableView) - view.addSubview(emptyStateLabel) - view.addSubview(fadeView) - view.addSubview(unblockButton) - setupLayout() - - // 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 viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - self.viewHasAppeared = true - self.autoLoadNextPageIfNeeded() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - // Stop observing database changes - dataChangeObservable?.cancel() - } - - @objc func applicationDidBecomeActive(_ notification: Notification) { - /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query - DispatchQueue.main.async { [weak self] in - self?.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, constant: Values.largeSpacing), - tableView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -Values.largeSpacing), - tableView.bottomAnchor.constraint( - equalTo: unblockButton.topAnchor, - constant: -Values.largeSpacing - ), - - 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.leftAnchor.constraint(equalTo: view.leftAnchor), - fadeView.rightAnchor.constraint(equalTo: view.rightAnchor), - fadeView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - unblockButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - unblockButton.bottomAnchor.constraint( - equalTo: view.safeAreaLayoutGuide.bottomAnchor, - constant: -Values.smallSpacing - ), - unblockButton.widthAnchor.constraint(equalToConstant: Values.iPadButtonWidth) - ]) - } - - // MARK: - Updating - - private func startObservingChanges(didReturnFromBackground: Bool = false) { - self.viewModel.onContactChange = { [weak self] updatedContactData, changeset in - self?.handleContactUpdates(updatedContactData, changeset: changeset) - } - - // 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 handleContactUpdates( - _ updatedData: [BlockedContactsViewModel.SectionModel], - changeset: StagedChangeset<[BlockedContactsViewModel.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 hasLoadedInitialContactData else { - hasLoadedInitialContactData = true - UIView.performWithoutAnimation { - handleContactUpdates(updatedData, changeset: changeset, initialLoad: true) - } - return - } - - // Show the empty state if there is no data - let hasContactsData: Bool = (updatedData - .first(where: { $0.model == .contacts })? - .elements - .isEmpty == false) - unblockButton.isEnabled = !viewModel.selectedContactIds.isEmpty - unblockButton.isHidden = !hasContactsData - emptyStateLabel.isHidden = hasContactsData - - 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: changeset, - 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.updateContactData(updatedData) - } - - CATransaction.commit() - } - - private func autoLoadNextPageIfNeeded() { - guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } - - self.isAutoLoadingNextPage = true - - DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in - self?.isAutoLoadingNextPage = false - - // Note: We sort the headers as we want to prioritise loading newer pages over older ones - let sections: [(BlockedContactsViewModel.Section, CGRect)] = (self?.viewModel.contactData - .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: .userInitiated).async { [weak self] in - self?.viewModel.pagedDataObserver?.load(.pageAfter) - } - } - } - - // MARK: - UITableViewDataSource - - func numberOfSections(in tableView: UITableView) -> Int { - return viewModel.contactData.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[section] - - return section.elements.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[indexPath.section] - - switch section.model { - case .contacts: - let info: SessionCell.Info = section.elements[indexPath.row] - let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath) - cell.update( - with: info, - style: .roundedEdgeToEdge, - position: Position.with(indexPath.row, count: section.elements.count) - ) - - return cell - - default: preconditionFailure("Other sections should have no content") - } - } - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[section] - - switch section.model { - case .loadMore: - let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium) - loadingIndicator.themeTintColor = .textPrimary - 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, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - let section: BlockedContactsViewModel.SectionModel = viewModel.contactData[section] - - switch section.model { - case .loadMore: return BlockedContactsViewController.loadingHeaderHeight - default: return 0 - } - } - - func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - guard self.hasLoadedInitialContactData && self.viewHasAppeared && !self.isLoadingMore else { return } - - let section: BlockedContactsViewModel.SectionModel = self.viewModel.contactData[section] - - switch section.model { - case .loadMore: - self.isLoadingMore = true - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - self?.viewModel.pagedDataObserver?.load(.pageAfter) - } - - default: break - } - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - - let section: BlockedContactsViewModel.SectionModel = self.viewModel.contactData[indexPath.section] - - switch section.model { - case .contacts: - let info: SessionCell.Info = section.elements[indexPath.row] - - // Do nothing if the item is disabled - guard info.isEnabled else { return } - - // Get the view that was tapped (for presenting on iPad) - let tappedView: UIView? = tableView.cellForRow(at: indexPath) - let maybeOldSelection: (Int, SessionCell.Info)? = section.elements - .enumerated() - .first(where: { index, info in - switch (info.leftAccessory, info.rightAccessory) { - case (_, .radio(_, let isSelected, _)): return isSelected() - case (.radio(_, let isSelected, _), _): return isSelected() - default: return false - } - }) - - info.onTap?(tappedView) - self.manuallyReload(indexPath: indexPath, section: section, info: info) - self.unblockButton.isEnabled = !self.viewModel.selectedContactIds.isEmpty - - // Update the old selection as well - if let oldSelection: (index: Int, info: SessionCell.Info) = maybeOldSelection { - self.manuallyReload( - indexPath: IndexPath( - row: oldSelection.index, - section: indexPath.section - ), - section: section, - info: oldSelection.info - ) - } - - default: break - } - } - - private func manuallyReload( - indexPath: IndexPath, - section: BlockedContactsViewModel.SectionModel, - info: SessionCell.Info - ) { - // Try update the existing cell to have a nice animation instead of reloading the cell - if let existingCell: SessionCell = tableView.cellForRow(at: indexPath) as? SessionCell { - existingCell.update( - with: info, - style: .roundedEdgeToEdge, - position: Position.with(indexPath.row, count: section.elements.count) - ) - } - else { - tableView.reloadRows(at: [indexPath], with: .none) - } - } - - // MARK: - Interaction - - @objc private func unblockTapped() { - guard !viewModel.selectedContactIds.isEmpty else { return } - - let contactIds: Set = viewModel.selectedContactIds - let contactNames: [String] = contactIds - .map { contactId in - guard - let section: BlockedContactsViewModel.SectionModel = self.viewModel.contactData - .first(where: { section in section.model == .contacts }), - let info: SessionCell.Info = section.elements - .first(where: { info in info.id.id == contactId }) - else { return contactId } - - return info.title - } - let confirmationTitle: String = { - guard contactNames.count > 1 else { - // Show a single users name - return String( - format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE".localized(), - ( - contactNames.first ?? - "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_FALLBACK".localized() - ) - ) - } - guard contactNames.count > 3 else { - // Show up to three users names - let initialNames: [String] = Array(contactNames.prefix(upTo: (contactNames.count - 1))) - let lastName: String = contactNames[contactNames.count - 1] - - return [ - String( - format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(), - initialNames.joined(separator: ", ") - ), - String( - format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_2_SINGLE".localized(), - lastName - ) - ] - .reversed(if: CurrentAppContext().isRTL) - .joined(separator: " ") - } - - // If we have exactly 4 users, show the first two names followed by 'and X others', for - // more than 4 users, show the first 3 names followed by 'and X others' - let numNamesToShow: Int = (contactNames.count == 4 ? 2 : 3) - let initialNames: [String] = Array(contactNames.prefix(upTo: numNamesToShow)) - - return [ - String( - format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(), - initialNames.joined(separator: ", ") - ), - String( - format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_3".localized(), - (contactNames.count - numNamesToShow) - ) - ] - .reversed(if: CurrentAppContext().isRTL) - .joined(separator: " ") - }() - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: confirmationTitle, - confirmTitle: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text - ) { _ in - // Unblock the contacts - Storage.shared.write { db in - _ = try Contact - .filter(ids: contactIds) - .updateAll(db, Contact.Columns.isBlocked.set(to: false)) - - // Force a config sync - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - } - } - ) - self.present(confirmationModal, animated: true, completion: nil) - } -} diff --git a/Session/Settings/BlockedContactsViewModel.swift b/Session/Settings/BlockedContactsViewModel.swift index 1a414edf3..871bae502 100644 --- a/Session/Settings/BlockedContactsViewModel.swift +++ b/Session/Settings/BlockedContactsViewModel.swift @@ -1,18 +1,25 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import DifferenceKit +import SessionUIKit import SignalUtilitiesKit -public class BlockedContactsViewModel { - public typealias SectionModel = ArraySection> - +class BlockedContactsViewModel: SessionTableViewModel { // MARK: - Section - public enum Section: Differentiable { + public enum Section: SessionTableSection { case contacts case loadMore + + var style: SessionTableSectionStyle { + switch self { + case .contacts: return .none + case .loadMore: return .loadMore + } + } } // MARK: - Variables @@ -21,14 +28,16 @@ public class BlockedContactsViewModel { // MARK: - Initialization - init() { - self.pagedDataObserver = nil + override init() { + _pagedDataObserver = nil + + super.init() // 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( + _pagedDataObserver = PagedDatabaseObserver( pagedTable: Profile.self, pageSize: BlockedContactsViewModel.pageSize, idColumn: .id, @@ -63,12 +72,13 @@ public class BlockedContactsViewModel { ), onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in PagedData.processAndTriggerUpdates( - updatedData: self?.process(data: updatedData, for: updatedPageInfo), - currentDataRetriever: { self?.contactData }, - onDataChange: self?.onContactChange, - onUnobservedDataChange: { updatedData, changeset in - self?.unobservedContactDataChanges = (updatedData, changeset) - } + updatedData: self?.process(data: updatedData, for: updatedPageInfo) + .mapToSessionTableViewData(for: self), + currentDataRetriever: { self?.tableData }, + onDataChange: { updatedData, changeset in + self?.contactDataSubject.send((updatedData, changeset)) + }, + onUnobservedDataChange: { _, _ in } ) } ) @@ -76,59 +86,80 @@ public class BlockedContactsViewModel { // Run the initial query on a background thread so we don't block the push transition DispatchQueue.global(qos: .userInitiated).async { [weak self] in // The `.pageBefore` will query from a `0` offset loading the first page - self?.pagedDataObserver?.load(.pageBefore) + self?._pagedDataObserver?.load(.pageBefore) } } // MARK: - Contact Data - public private(set) var selectedContactIds: Set = [] - public private(set) var unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)? - public private(set) var contactData: [SectionModel] = [] - public private(set) var pagedDataObserver: PagedDatabaseObserver? - - public var onContactChange: (([SectionModel], StagedChangeset<[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 unobservedContactDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedContactDataChanges { - onContactChange?(unobservedContactDataChanges.0 , unobservedContactDataChanges.1) - self.unobservedContactDataChanges = nil - } - } + override var title: String { "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE".localized() } + override var emptyStateTextPublisher: AnyPublisher { + Just("CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE".localized()) + .eraseToAnyPublisher() } - private func process(data: [DataModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] { - // Update the 'selectedContactIds' to only include selected contacts which are within the - // data (ie. handle profile deletions) - let profileIds: Set = data.map { $0.id }.asSet() - selectedContactIds = selectedContactIds.intersection(profileIds) - + private let contactDataSubject: CurrentValueSubject<([SectionModel], StagedChangeset<[SectionModel]>), Never> = CurrentValueSubject(([], StagedChangeset())) + private let selectedContactIdsSubject: CurrentValueSubject, Never> = CurrentValueSubject([]) + private var _pagedDataObserver: PagedDatabaseObserver? + public override var pagedDataObserver: TransactionObserver? { _pagedDataObserver } + + public override var observableTableData: ObservableData { _observableTableData } + + private lazy var _observableTableData: ObservableData = contactDataSubject + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + + override var footerButtonInfo: AnyPublisher { + selectedContactIdsSubject + .prepend([]) + .map { selectedContactIds in + SessionButton.Info( + style: .destructive, + title: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK".localized(), + isEnabled: !selectedContactIds.isEmpty, + onTap: { [weak self] in self?.unblockTapped() } + ) + } + .eraseToAnyPublisher() + } + + // MARK: - Functions + + override func loadPageAfter() { _pagedDataObserver?.load(.pageAfter) } + + private func process( + data: [DataModel], + for pageInfo: PagedData.PageInfo + ) -> [SectionModel] { return [ [ SectionModel( section: .contacts, elements: data .sorted { lhs, rhs -> Bool in - lhs.profile.displayName() > rhs.profile.displayName() + lhs.profile.displayName() < rhs.profile.displayName() } - .map { model -> SessionCell.Info in + .map { [weak self] model -> SessionCell.Info in SessionCell.Info( id: model.profile, - leftAccessory: .profile(model.profile.id, model.profile), + leftAccessory: .profile(id: model.profile.id, profile: model.profile), title: model.profile.displayName(), rightAccessory: .radio( - isSelected: { [weak self] in - self?.selectedContactIds.contains(model.profile.id) == true + isSelected: { + self?.selectedContactIdsSubject.value.contains(model.profile.id) == true } ), - onTap: { [weak self] in - guard self?.selectedContactIds.contains(model.profile.id) == true else { - self?.selectedContactIds.insert(model.profile.id) - return + onTap: { + var updatedSelectedIds: Set = (self?.selectedContactIdsSubject.value ?? []) + + if !updatedSelectedIds.contains(model.profile.id) { + updatedSelectedIds.insert(model.profile.id) + } + else { + updatedSelectedIds.remove(model.profile.id) } - self?.selectedContactIds.remove(model.profile.id) + self?.selectedContactIdsSubject.send(updatedSelectedIds) } ) } @@ -141,8 +172,87 @@ public class BlockedContactsViewModel { ].flatMap { $0 } } - public func updateContactData(_ updatedData: [SectionModel]) { - self.contactData = updatedData + private func unblockTapped() { + guard !selectedContactIdsSubject.value.isEmpty else { return } + + let contactIds: Set = selectedContactIdsSubject.value + let contactNames: [String] = contactIds + .compactMap { contactId in + guard + let section: BlockedContactsViewModel.SectionModel = self.tableData + .first(where: { section in section.model == .contacts }), + let info: SessionCell.Info = section.elements + .first(where: { info in info.id.id == contactId }) + else { return contactId } + + return info.title?.text + } + let confirmationTitle: String = { + guard contactNames.count > 1 else { + // Show a single users name + return String( + format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE".localized(), + ( + contactNames.first ?? + "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_FALLBACK".localized() + ) + ) + } + guard contactNames.count > 3 else { + // Show up to three users names + let initialNames: [String] = Array(contactNames.prefix(upTo: (contactNames.count - 1))) + let lastName: String = contactNames[contactNames.count - 1] + + return [ + String( + format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(), + initialNames.joined(separator: ", ") + ), + String( + format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_2_SINGLE".localized(), + lastName + ) + ] + .reversed(if: CurrentAppContext().isRTL) + .joined(separator: " ") + } + + // If we have exactly 4 users, show the first two names followed by 'and X others', for + // more than 4 users, show the first 3 names followed by 'and X others' + let numNamesToShow: Int = (contactNames.count == 4 ? 2 : 3) + let initialNames: [String] = Array(contactNames.prefix(upTo: numNamesToShow)) + + return [ + String( + format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1".localized(), + initialNames.joined(separator: ", ") + ), + String( + format: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_3".localized(), + (contactNames.count - numNamesToShow) + ) + ] + .reversed(if: CurrentAppContext().isRTL) + .joined(separator: " ") + }() + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: confirmationTitle, + confirmTitle: "CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON".localized(), + confirmStyle: .danger, + cancelStyle: .alert_text + ) { [weak self] _ in + // Unblock the contacts + Storage.shared.write { db in + _ = try Contact + .filter(ids: contactIds) + .updateAllAndConfig(db, Contact.Columns.isBlocked.set(to: false)) + } + + self?.selectedContactIdsSubject.send([]) + } + ) + self.transitionToScreen(confirmationModal, transitionType: .present) } // MARK: - DataModel @@ -162,8 +272,8 @@ public class BlockedContactsViewModel { static func query( filterSQL: SQL, orderSQL: SQL - ) -> (([Int64]) -> AdaptedFetchRequest>) { - return { rowIds -> AdaptedFetchRequest> in + ) -> (([Int64]) -> any FetchRequest) { + return { rowIds -> any FetchRequest in let profile: TypedTableAlias = TypedTableAlias() /// **Note:** The `numColumnsBeforeProfile` value **MUST** match the number of fields before diff --git a/Session/Settings/ConversationSettingsViewModel.swift b/Session/Settings/ConversationSettingsViewModel.swift index c8b56ec10..1c6803913 100644 --- a/Session/Settings/ConversationSettingsViewModel.swift +++ b/Session/Settings/ConversationSettingsViewModel.swift @@ -26,7 +26,7 @@ class ConversationSettingsViewModel: SessionTableViewModel [SectionModel] in return [ SectionModel( @@ -92,10 +89,14 @@ class ConversationSettingsViewModel: SessionTableViewModel { +#if DEBUG + private var databaseKeyEncryptionPassword: String = "" +#endif + // MARK: - Section public enum Section: SessionTableSection { @@ -16,6 +22,9 @@ class HelpViewModel: SessionTableViewModel [SectionModel] in return [ SectionModel( @@ -49,7 +55,7 @@ class HelpViewModel: SessionTableViewModel ())? = nil ) { - let version: String = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) - .defaulting(to: "") - OWSLogger.info("[Version] iOS \(UIDevice.current.systemVersion) \(version)") + OWSLogger.info("[Version] \(SessionApp.versionInfo)") DDLog.flushLog() let logFilePaths: [String] = AppEnvironment.shared.fileLogger.logFileManager.sortedLogFilePaths @@ -178,7 +203,7 @@ class HelpViewModel: SessionTableViewModel= 6 else { + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Error", + body: .text("Password must be at least 6 characters") + ) + ), + transitionType: .present + ) + return + } + + do { + let exportInfo = try Storage.shared.exportInfo(password: password) + let shareVC = UIActivityViewController( + activityItems: [ + URL(fileURLWithPath: exportInfo.dbPath), + URL(fileURLWithPath: exportInfo.keyPath) + ], + applicationActivities: nil + ) + shareVC.completionWithItemsHandler = { [weak self] _, completed, _, _ in + guard + completed && + generatedPassword == self?.databaseKeyEncryptionPassword + else { return } + + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Password", + body: .text(""" + The generated password was: + \(generatedPassword) + + Avoid sending this via the same means as the database + """), + confirmTitle: "Share", + dismissOnConfirm: false, + onConfirm: { [weak self] modal in + modal.dismiss(animated: true) { + let passwordShareVC = UIActivityViewController( + activityItems: [generatedPassword], + applicationActivities: nil + ) + if UIDevice.current.isIPad { + passwordShareVC.excludedActivityTypes = [] + passwordShareVC.popoverPresentationController?.permittedArrowDirections = (targetView != nil ? [.up] : []) + passwordShareVC.popoverPresentationController?.sourceView = targetView + passwordShareVC.popoverPresentationController?.sourceRect = (targetView?.bounds ?? .zero) + } + + self?.transitionToScreen(passwordShareVC, transitionType: .present) + } + } + ) + ), + transitionType: .present + ) + } + + if UIDevice.current.isIPad { + shareVC.excludedActivityTypes = [] + shareVC.popoverPresentationController?.permittedArrowDirections = (targetView != nil ? [.up] : []) + shareVC.popoverPresentationController?.sourceView = targetView + shareVC.popoverPresentationController?.sourceRect = (targetView?.bounds ?? .zero) + } + + self?.transitionToScreen(shareVC, transitionType: .present) + } + catch { + let message: String = { + switch error { + case CryptoKitError.incorrectKeySize: + return "The password must be between 6 and 32 characters (padded to 32 bytes)" + + default: return "Failed to export database" + } + }() + + self?.transitionToScreen( + ConfirmationModal( + info: ConfirmationModal.Info( + title: "Error", + body: .text(message) + ) + ), + transitionType: .present + ) + } + } + } + ) + ), + transitionType: .present + ) + } +#endif } diff --git a/Session/Settings/ImagePickerHandler.swift b/Session/Settings/ImagePickerHandler.swift new file mode 100644 index 000000000..6182f6fc2 --- /dev/null +++ b/Session/Settings/ImagePickerHandler.swift @@ -0,0 +1,58 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigationControllerDelegate { + private let onTransition: (UIViewController, TransitionType) -> Void + private let onImageDataPicked: (Data) -> Void + + // MARK: - Initialization + + init( + onTransition: @escaping (UIViewController, TransitionType) -> Void, + onImageDataPicked: @escaping (Data) -> Void + ) { + self.onTransition = onTransition + self.onImageDataPicked = onImageDataPicked + } + + // MARK: - UIImagePickerControllerDelegate + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + guard + let imageUrl: URL = info[.imageURL] as? URL, + let rawAvatar: UIImage = info[.originalImage] as? UIImage + else { + picker.presentingViewController?.dismiss(animated: true) + return + } + + picker.presentingViewController?.dismiss(animated: true) { [weak self] in + // Check if the user selected an animated image (if so then don't crop, just + // set the avatar directly + guard + let resourceValues: URLResourceValues = (try? imageUrl.resourceValues(forKeys: [.typeIdentifierKey])), + let type: Any = resourceValues.allValues.first?.value, + let typeString: String = type as? String, + MIMETypeUtil.supportedAnimatedImageUTITypes().contains(typeString) + else { + let viewController: CropScaleImageViewController = CropScaleImageViewController( + srcImage: rawAvatar, + successCompletion: { resultImageData in + self?.onImageDataPicked(resultImageData) + } + ) + self?.onTransition(viewController, .present) + return + } + + guard let imageData: Data = try? Data(contentsOf: URL(fileURLWithPath: imageUrl.path)) else { return } + + self?.onImageDataPicked(imageData) + } + } +} diff --git a/Session/Settings/NotificationContentViewModel.swift b/Session/Settings/NotificationContentViewModel.swift index c32e79dad..a6a6e5567 100644 --- a/Session/Settings/NotificationContentViewModel.swift +++ b/Session/Settings/NotificationContentViewModel.swift @@ -31,10 +31,7 @@ class NotificationContentViewModel: SessionTableViewModel [SectionModel] in let currentSelection: Preferences.NotificationPreviewType? = db[.preferencesNotificationPreviewType] .defaulting(to: .defaultPreviewType) @@ -72,11 +69,7 @@ class NotificationContentViewModel: SessionTableViewModel [SectionModel] in let notificationSound: Preferences.Sound = db[.defaultNotificationSound] .defaulting(to: Preferences.Sound.defaultNotificationSound) @@ -72,9 +70,9 @@ class NotificationSettingsViewModel: SessionTableViewModel [SectionModel] in self?.storedSelection = try { guard let threadId: String = self?.threadId else { @@ -149,13 +146,11 @@ class NotificationSoundViewModel: SessionTableViewModel [SectionModel] in return [ SectionModel( @@ -145,34 +142,40 @@ class PrivacySettingsViewModel: SessionTableViewModel ())?) { let hexEncodedPublicKey = string - startNewPrivateChatIfPossible(with: hexEncodedPublicKey) + startNewPrivateChatIfPossible(with: hexEncodedPublicKey, onError: onError) } - fileprivate func startNewPrivateChatIfPossible(with hexEncodedPublicKey: String) { - if !ECKeyPair.isValidHexEncodedPublicKey(candidate: hexEncodedPublicKey) { + fileprivate func startNewPrivateChatIfPossible(with hexEncodedPublicKey: String, onError: (() -> ())?) { + if !KeyPair.isValidHexEncodedPublicKey(candidate: hexEncodedPublicKey) { let modal: ConfirmationModal = ConfirmationModal( targetView: self.view, info: ConfirmationModal.Info( title: "invalid_session_id".localized(), body: .text("INVALID_SESSION_ID_MESSAGE".localized()), cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text + cancelStyle: .alert_text, + afterClosed: onError ) ) self.present(modal, animated: true) } 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) - - SessionApp.presentConversation(for: hexEncodedPublicKey, action: .compose, animated: false) + SessionApp.presentConversationCreatingIfNeeded( + for: hexEncodedPublicKey, + variant: .contact, + dismissing: presentingViewController, + animated: false + ) } } } diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 509adaee6..cbf08f836 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -3,6 +3,7 @@ import Foundation import Combine import GRDB +import YYImage import DifferenceKit import SessionUIKit import SessionMessagingKit @@ -26,12 +27,33 @@ class SettingsViewModel: SessionTableViewModel = { - isEditing - .map { isEditing in (isEditing ? .editing : .standard) } + Publishers + .CombineLatest( + isEditing, + textChanged + .handleEvents( + receiveOutput: { [weak self] value, _ in + self?.editedDisplayName = value + } + ) + .filter { _ in false } + .prepend((nil, .profileName)) + ) + .map { isEditing, _ -> NavState in (isEditing ? .editing : .standard) } .removeDuplicates() .prepend(.standard) // Initial value + .shareReplay(1) .eraseToAnyPublisher() }() @@ -106,9 +148,7 @@ class SettingsViewModel: SessionTableViewModel { - let userSessionId: String = self.userSessionId - - return navState + navState .map { [weak self] navState -> [NavItem] in switch navState { case .standard: @@ -166,10 +206,7 @@ class SettingsViewModel: SessionTableViewModel [SectionModel] in + private lazy var _observableTableData: ObservableData = ValueObservation + .trackingConstantRegion { [weak self] db -> [SectionModel] in let userPublicKey: String = getUserHexEncodedPublicKey(db) let profile: Profile = Profile.fetchOrCreateCurrentUser(db) @@ -204,38 +238,89 @@ class SettingsViewModel: SessionTableViewModel { Just(VersionFooterView()) @@ -389,53 +476,39 @@ class SettingsViewModel: SessionTableViewModel ())? = nil ) { let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self] modalActivityIndicator in ProfileManager.updateLocal( - queue: DispatchQueue.global(qos: .default), + queue: .global(qos: .default), profileName: name, - image: profilePicture, - imageFilePath: profilePictureFilePath, - success: { db, updatedProfile in - if isUpdatingDisplayName { - UserDefaults.standard[.lastDisplayNameUpdate] = Date() - } - - if isUpdatingProfilePicture { - UserDefaults.standard[.lastProfilePictureUpdate] = Date() - } - - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - + avatarUpdate: avatarUpdate, + success: { db in // Wait for the database transaction to complete before updating the UI - db.afterNextTransaction { _ in + db.afterNextTransactionNested { _ in DispatchQueue.main.async { modalActivityIndicator.dismiss(completion: { onComplete?() @@ -531,19 +589,30 @@ class SettingsViewModel: SessionTableViewModel 0 ? + let threadIsUnread: Bool = ( + unreadCount > 0 || + cellViewModel.threadWasMarkedUnread == true + ) + let themeBackgroundColor: ThemeValue = (threadIsUnread ? .conversationButton_unreadBackground : .conversationButton_background ) @@ -354,27 +395,29 @@ public final class FullConversationCell: UITableViewCell { accentLineView.alpha = (unreadCount > 0 ? 1 : 0) } - isPinnedIcon.isHidden = !cellViewModel.threadIsPinned + isPinnedIcon.isHidden = (cellViewModel.threadPinnedPriority == 0) unreadCountView.isHidden = (unreadCount <= 0) - unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") + unreadImageView.isHidden = (!unreadCountView.isHidden || !threadIsUnread) + unreadCountLabel.text = (unreadCount <= 0 ? + "" : + (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) + ((cellViewModel.threadUnreadMentionCount ?? 0) > 0) && ( + cellViewModel.threadVariant == .legacyGroup || + cellViewModel.threadVariant == .group || + cellViewModel.threadVariant == .community + ) ) profilePictureView.update( publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, threadVariant: cellViewModel.threadVariant, - openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData, - useFallbackPicture: ( - cellViewModel.threadVariant == .openGroup && - cellViewModel.openGroupProfilePictureData == nil - ), - showMultiAvatarForClosedGroup: true + customImageData: cellViewModel.openGroupProfilePictureData, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile ) displayNameLabel.text = cellViewModel.displayName timestampLabel.text = cellViewModel.lastInteractionDate.formattedForDisplay @@ -385,6 +428,13 @@ public final class FullConversationCell: UITableViewCell { typingIndicatorView.startAnimation() } else { + displayNameLabel.themeTextColor = { + guard cellViewModel.interactionVariant != .infoClosedGroupCurrentUserLeaving else { + return .textSecondary + } + + return .textPrimary + }() typingIndicatorView.isHidden = true typingIndicatorView.stopAnimation() @@ -392,8 +442,6 @@ public final class FullConversationCell: UITableViewCell { if cellViewModel.interactionVariant == .infoClosedGroupCurrentUserLeaving { guard let textColor: UIColor = theme.color(for: .textSecondary) else { return } - self?.displayNameLabel.themeTextColor = .textSecondary - snippetLabel?.attributedText = self?.getSnippet( cellViewModel: cellViewModel, textColor: textColor @@ -401,8 +449,6 @@ public final class FullConversationCell: UITableViewCell { } else if cellViewModel.interactionVariant == .infoClosedGroupCurrentUserErrorLeaving { guard let textColor: UIColor = theme.color(for: .danger) else { return } - self?.displayNameLabel.themeTextColor = .textPrimary - snippetLabel?.attributedText = self?.getSnippet( cellViewModel: cellViewModel, textColor: textColor @@ -410,8 +456,6 @@ public final class FullConversationCell: UITableViewCell { } else { guard let textColor: UIColor = theme.color(for: .textPrimary) else { return } - self?.displayNameLabel.themeTextColor = .textPrimary - snippetLabel?.attributedText = self?.getSnippet( cellViewModel: cellViewModel, textColor: textColor @@ -432,16 +476,45 @@ public final class FullConversationCell: UITableViewCell { ) } + // MARK: - SwipeActionOptimisticCell + public func optimisticUpdate( - isMuted: Bool? = nil, - isPinned: Bool? = nil, - hasUnread: Bool? = nil + isMuted: Bool?, + isBlocked: Bool?, + isPinned: Bool?, + hasUnread: Bool? ) { + // Note: This will result in the snippet being out of sync while the swipe action animation completes, + // this means if the day/night mode changes while the animation is happening then the below optimistic + // update might get reset (this should be rare and is a relatively minor bug so can be left in) if let isMuted: Bool = isMuted { - if isMuted { - - } else { - + let attrString: NSAttributedString = (self.snippetLabel.attributedText ?? NSAttributedString()) + let hasMutePrefix: Bool = attrString.string.starts(with: FullConversationCell.mutePrefix) + + switch (isMuted, hasMutePrefix) { + case (true, false): + self.snippetLabel.attributedText = NSAttributedString( + string: FullConversationCell.mutePrefix, + attributes: [ .font: UIFont(name: "ElegantIcons", size: 10) as Any ] + ) + .appending(attrString) + + case (false, true): + self.snippetLabel.attributedText = attrString + .attributedSubstring(from: NSRange(location: FullConversationCell.mutePrefix.count, length: (attrString.length - FullConversationCell.mutePrefix.count))) + + default: break + } + } + + if let isBlocked: Bool = isBlocked { + if isBlocked { + accentLineView.themeBackgroundColor = .danger + accentLineView.alpha = 1 + } + else { + accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground + accentLineView.alpha = (!unreadCountView.isHidden || !unreadImageView.isHidden ? 1 : 0) } } @@ -475,9 +548,9 @@ public final class FullConversationCell: UITableViewCell { if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) { result.append(NSAttributedString( - string: "\u{e067} ", + string: FullConversationCell.mutePrefix, attributes: [ - .font: UIFont.ows_elegantIconsFont(10), + .font: UIFont(name: "ElegantIcons", size: 10) as Any, .foregroundColor: textColor ] )) @@ -492,14 +565,14 @@ public final class FullConversationCell: UITableViewCell { result.append(NSAttributedString( string: " ", attributes: [ - .font: UIFont.ows_elegantIconsFont(10), + .font: UIFont(name: "ElegantIcons", size: 10) as Any, .foregroundColor: textColor ] )) } if - (cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup) && + (cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group || cellViewModel.threadVariant == .community) && (cellViewModel.interactionVariant?.isGroupControlMessage == false) { let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant) @@ -528,7 +601,8 @@ public final class FullConversationCell: UITableViewCell { in: previewText, threadVariant: cellViewModel.threadVariant, currentUserPublicKey: cellViewModel.currentUserPublicKey, - currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey + currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey ), attributes: [ .foregroundColor: textColor ] )) @@ -540,7 +614,8 @@ public final class FullConversationCell: UITableViewCell { content: String, authorName: String? = nil, currentUserPublicKey: String, - currentUserBlindedPublicKey: String?, + currentUserBlinded15PublicKey: String?, + currentUserBlinded25PublicKey: String?, searchText: String, fontSize: CGFloat, textColor: UIColor @@ -563,7 +638,8 @@ public final class FullConversationCell: UITableViewCell { in: content, threadVariant: .contact, currentUserPublicKey: currentUserPublicKey, - currentUserBlindedPublicKey: currentUserBlindedPublicKey + currentUserBlinded15PublicKey: currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: currentUserBlinded25PublicKey ) let result: NSMutableAttributedString = NSMutableAttributedString( string: mentionReplacedContent, @@ -581,8 +657,7 @@ public final class FullConversationCell: UITableViewCell { .map { part -> String in guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part } - let partRange = (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): ...", + string: "\(authorName): ", attributes: [ .foregroundColor: textColor ] ) - return authorPrefix - .appending( - truncatingIfNeeded( - approxWidth: ( - authorPrefix.size().width + - ( - result.length > Self.maxApproxCharactersCanBeShownInOneLine ? - bounds.width : - result.size().width - ) - ), - content: result - ) - ) + return authorPrefix.appending(result) } - .defaulting( - to: truncatingIfNeeded( - approxWidth: ( - result.length > Self.maxApproxCharactersCanBeShownInOneLine ? - bounds.width : - result.size().width - ), - content: result - ) - ) + .defaulting(to: result) } } diff --git a/Session/Shared/LoadingViewController.swift b/Session/Shared/LoadingViewController.swift index d3bd6ccce..7b5021fc2 100644 --- a/Session/Shared/LoadingViewController.swift +++ b/Session/Shared/LoadingViewController.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import PromiseKit import SessionUIKit // The initial presentation is intended to be indistinguishable from the Launch Screen. diff --git a/Session/Shared/OWSBezierPathView.h b/Session/Shared/OWSBezierPathView.h index 6cb0201a8..c3aea7400 100644 --- a/Session/Shared/OWSBezierPathView.h +++ b/Session/Shared/OWSBezierPathView.h @@ -2,6 +2,8 @@ // Copyright (c) 2017 Open Whisper Systems. All rights reserved. // +#import + typedef void (^ConfigureShapeLayerBlock)(CAShapeLayer *_Nonnull layer, CGRect bounds); NS_ASSUME_NONNULL_BEGIN diff --git a/Session/Shared/OWSBezierPathView.m b/Session/Shared/OWSBezierPathView.m index eaac80fc0..eaddf67a7 100644 --- a/Session/Shared/OWSBezierPathView.m +++ b/Session/Shared/OWSBezierPathView.m @@ -2,7 +2,9 @@ // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // +#import #import "OWSBezierPathView.h" +#import NS_ASSUME_NONNULL_BEGIN diff --git a/Session/Shared/QRCodeScanningViewController.swift b/Session/Shared/QRCodeScanningViewController.swift index 011823fa1..b29db5fa5 100644 --- a/Session/Shared/QRCodeScanningViewController.swift +++ b/Session/Shared/QRCodeScanningViewController.swift @@ -2,55 +2,36 @@ import UIKit import AVFoundation -import ZXingObjC import SessionUIKit +import SessionUtilitiesKit protocol QRScannerDelegate: AnyObject { - func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String) + func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String, onError: (() -> ())?) } -class QRCodeScanningViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate, ZXCaptureDelegate { +class QRCodeScanningViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { public weak var scanDelegate: QRScannerDelegate? private let captureQueue: DispatchQueue = DispatchQueue.global(qos: .default) - private var capture: ZXCapture? + private var capture: AVCaptureSession? + private var captureLayer: AVCaptureVideoPreviewLayer? private var captureEnabled: Bool = false // MARK: - Initialization deinit { - self.capture?.layer.removeFromSuperlayer() + self.captureLayer?.removeFromSuperlayer() } // MARK: - Components - private let maskingView: UIView = { - let result: OWSBezierPathView = OWSBezierPathView() - result.configureShapeLayerBlock = { layer, bounds in - // Add a circular mask - let path: UIBezierPath = UIBezierPath(rect: bounds) - let margin: CGFloat = ScaleFromIPhone5To7Plus(24, 48) - let radius: CGFloat = ((min(bounds.size.width, bounds.size.height) * 0.5) - margin) - - // Center the circle's bounding rectangle - let circleRect: CGRect = CGRect( - x: ((bounds.size.width * 0.5) - radius), - y: ((bounds.size.height * 0.5) - radius), - width: (radius * 2), - height: (radius * 2) - ) - let circlePath: UIBezierPath = UIBezierPath.init( - roundedRect: circleRect, - cornerRadius: 16 - ) - path.append(circlePath) - path.usesEvenOddFillRule = true - - layer.path = path.cgPath - layer.fillRule = .evenOdd - layer.themeFillColor = .black - layer.opacity = 0.32 - } + private let maskingView: UIView = UIView() + + private lazy var maskLayer: CAShapeLayer = { + let result: CAShapeLayer = CAShapeLayer() + result.fillRule = .evenOdd + result.themeFillColor = .black + result.opacity = 0.32 return result }() @@ -61,7 +42,8 @@ class QRCodeScanningViewController: UIViewController, AVCaptureMetadataOutputObj super.loadView() self.view.addSubview(maskingView) - maskingView.pin(to: self.view) + + maskingView.layer.addSublayer(maskLayer) } override func viewWillAppear(_ animated: Bool) { @@ -81,11 +63,28 @@ class QRCodeScanningViewController: UIViewController, AVCaptureMetadataOutputObj override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() - // Note: When accessing 'capture.layer' if the setup hasn't been completed it - // will result in a layout being triggered which creates an infinite loop, this - // check prevents that case - if let capture: ZXCapture = self.capture { - capture.layer.frame = self.view.bounds + captureLayer?.frame = self.view.bounds + + if maskingView.frame != self.view.bounds { + // Add a circular mask + let path: UIBezierPath = UIBezierPath(rect: self.view.bounds) + let radius: CGFloat = ((min(self.view.bounds.size.width, self.view.bounds.size.height) * 0.5) - Values.largeSpacing) + + // Center the circle's bounding rectangle + let circleRect: CGRect = CGRect( + x: ((self.view.bounds.size.width * 0.5) - radius), + y: ((self.view.bounds.size.height * 0.5) - radius), + width: (radius * 2), + height: (radius * 2) + ) + let clippingPath: UIBezierPath = UIBezierPath.init( + roundedRect: circleRect, + cornerRadius: 16 + ) + path.append(clippingPath) + path.usesEvenOddFillRule = true + + maskLayer.path = path.cgPath } } @@ -101,31 +100,76 @@ class QRCodeScanningViewController: UIViewController, AVCaptureMetadataOutputObj #else if self.capture == nil { self.captureQueue.async { [weak self] in - let capture: ZXCapture = ZXCapture() - capture.camera = capture.back() - capture.focusMode = .autoFocus - capture.delegate = self - capture.start() + let maybeDevice: AVCaptureDevice? = { + if let result = AVCaptureDevice.default(.builtInDualCamera, for: .video, position: .back) { + return result + } + + return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + }() - // Note: When accessing the 'layer' for the first time it will create - // an instance of 'AVCaptureVideoPreviewLayer', this can hang a little - // so we do this on the background thread first - if capture.layer != nil {} + // Set the input device to autoFocus (since we don't have the interaction setup for + // doing it manually) + maybeDevice?.focusMode = .continuousAutoFocus + + // Device input + guard + let device: AVCaptureDevice = maybeDevice, + let input: AVCaptureInput = try? AVCaptureDeviceInput(device: device) + else { + return SNLog("Failed to retrieve the device for enabling the QRCode scanning camera") + } + + // Image output + let output: AVCaptureVideoDataOutput = AVCaptureVideoDataOutput() + output.alwaysDiscardsLateVideoFrames = true + + // Metadata output the session + let metadataOutput: AVCaptureMetadataOutput = AVCaptureMetadataOutput() + metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + + let capture: AVCaptureSession = AVCaptureSession() + capture.beginConfiguration() + if capture.canAddInput(input) { capture.addInput(input) } + if capture.canAddOutput(output) { capture.addOutput(output) } + if capture.canAddOutput(metadataOutput) { capture.addOutput(metadataOutput) } + + guard !capture.inputs.isEmpty && capture.outputs.count == 2 else { + return SNLog("Failed to attach the input/output to the capture session") + } + + guard metadataOutput.availableMetadataObjectTypes.contains(.qr) else { + return SNLog("The output is unable to process QR codes") + } + + // Specify that we want to capture QR Codes (Needs to be done after being added + // to the session, 'availableMetadataObjectTypes' is empty beforehand) + metadataOutput.metadataObjectTypes = [.qr] + + capture.commitConfiguration() + + // Create the layer for rendering the camera video + let layer: AVCaptureVideoPreviewLayer = AVCaptureVideoPreviewLayer(session: capture) + layer.videoGravity = AVLayerVideoGravity.resizeAspectFill + + // Start running the capture session + capture.startRunning() DispatchQueue.main.async { - capture.layer.frame = (self?.view.bounds ?? .zero) - self?.view.layer.addSublayer(capture.layer) + layer.frame = (self?.view.bounds ?? .zero) + self?.view.layer.addSublayer(layer) if let maskingView: UIView = self?.maskingView { self?.view.bringSubviewToFront(maskingView) } self?.capture = capture + self?.captureLayer = layer } } } else { - self.capture?.start() + self.capture?.startRunning() } #endif } @@ -133,18 +177,25 @@ class QRCodeScanningViewController: UIViewController, AVCaptureMetadataOutputObj private func stopCapture() { self.captureEnabled = false self.captureQueue.async { [weak self] in - self?.capture?.stop() + self?.capture?.stopRunning() } } - internal func captureResult(_ capture: ZXCapture, result: ZXResult) { - guard self.captureEnabled else { return } + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + guard + self.captureEnabled, + let metadata: AVMetadataObject = metadataObjects.first(where: { ($0 as? AVMetadataMachineReadableCodeObject)?.type == .qr }), + let qrCodeInfo: AVMetadataMachineReadableCodeObject = metadata as? AVMetadataMachineReadableCodeObject, + let qrCode: String = qrCodeInfo.stringValue + else { return } self.stopCapture() // Vibrate AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) - - self.scanDelegate?.controller(self, didDetectQRCodeWith: result.text) + + self.scanDelegate?.controller(self, didDetectQRCodeWith: qrCode) { [weak self] in + self?.startCapture() + } } } diff --git a/Session/Shared/ScreenLockUI.swift b/Session/Shared/ScreenLockUI.swift index 8b096493a..dcf7d55c6 100644 --- a/Session/Shared/ScreenLockUI.swift +++ b/Session/Shared/ScreenLockUI.swift @@ -5,6 +5,7 @@ import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit +import SignalCoreKit class ScreenLockUI { public static let shared: ScreenLockUI = ScreenLockUI() @@ -14,7 +15,7 @@ class ScreenLockUI { result.isHidden = false result.windowLevel = ._Background result.isOpaque = true - result.themeBackgroundColor = .backgroundPrimary + result.themeBackgroundColorForced = .theme(.classicDark, color: .backgroundPrimary) result.rootViewController = self.screenBlockingViewController return result @@ -290,7 +291,7 @@ class ScreenLockUI { window.isHidden = false window.windowLevel = ._Background window.isOpaque = true - window.themeBackgroundColor = .backgroundPrimary + window.themeBackgroundColorForced = .theme(.classicDark, color: .backgroundPrimary) let viewController: ScreenLockViewController = ScreenLockViewController { [weak self] in guard self?.appIsInactiveOrBackground == false else { diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 4dbdba19c..5a825f5fc 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -16,7 +16,10 @@ class SessionTableViewController.SectionModel private let viewModel: SessionTableViewModel - private var hasLoadedInitialSettingsData: Bool = false + private var hasLoadedInitialTableData: Bool = false + private var isLoadingMore: Bool = false + private var isAutoLoadingNextPage: Bool = false + private var viewHasAppeared: Bool = false private var dataStreamJustFailed: Bool = false private var dataChangeCancellable: AnyCancellable? private var disposables: Set = Set() @@ -33,7 +36,6 @@ class SessionTableViewController) { self.viewModel = viewModel + Storage.shared.addObserver(viewModel.pagedDataObserver) + super.init(nibName: nil, bundle: nil) } @@ -99,6 +116,7 @@ class SessionTableViewController, + initialLoad: Bool = false + ) { + // Determine if we have any items for the empty state + let itemCount: Int = updatedData + .map { $0.elements.count } + .reduce(0, +) + // 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 hasLoadedInitialSettingsData else { - hasLoadedInitialSettingsData = true - UIView.performWithoutAnimation { handleSettingsUpdates(updatedData, initialLoad: true) } + guard hasLoadedInitialTableData else { + UIView.performWithoutAnimation { + // Update the empty state + emptyStateLabel.isHidden = (itemCount > 0) + + // Update the content + viewModel.updateTableData(updatedData) + tableView.reloadData() + hasLoadedInitialTableData = true + } return } + // Update the empty state + self.emptyStateLabel.isHidden = (itemCount > 0) + + 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.settingsData, target: updatedData), + using: changeset, deleteSectionsAnimation: .none, insertSectionsAnimation: .none, reloadSectionsAnimation: .none, - deleteRowsAnimation: .bottom, - insertRowsAnimation: .none, - reloadRowsAnimation: .none, + deleteRowsAnimation: .fade, + insertRowsAnimation: .fade, + reloadRowsAnimation: .fade, interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues ) { [weak self] updatedData in - self?.viewModel.updateSettings(updatedData) + self?.viewModel.updateTableData(updatedData) + } + + CATransaction.commit() + } + + private func autoLoadNextPageIfNeeded() { + guard + self.hasLoadedInitialTableData && + !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: [(Section, CGRect)] = (self?.viewModel.tableData + .enumerated() + .map { index, section in + (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) + }) + .defaulting(to: []) + let shouldLoadMore: Bool = sections + .contains { section, headerRect in + section.style == .loadMore && + headerRect != .zero && + (self?.tableView.bounds.contains(headerRect) == true) + } + + guard shouldLoadMore else { return } + + self?.isLoadingMore = true + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.viewModel.loadPageAfter() + } } } @@ -225,24 +313,32 @@ class SessionTableViewController Int { - return self.viewModel.settingsData.count + return self.viewModel.tableData.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return self.viewModel.settingsData[section].elements.count + return self.viewModel.tableData[section].elements.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let section: SectionModel = viewModel.settingsData[indexPath.section] + let section: SectionModel = viewModel.tableData[indexPath.section] let info: SessionCell.Info = section.elements[indexPath.row] + let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath) + cell.update(with: info) + cell.update( + isEditing: (self.isEditing || (info.title?.interaction == .alwaysEditing)), + becomeFirstResponder: false, + animated: false + ) + cell.textPublisher + .sink(receiveValue: { [weak self] text in + self?.viewModel.textChanged(text, for: info.id) + }) + .store(in: &cell.disposables) - switch info.leftAccessory { - case .threadInfo(let threadViewModel, let style, let avatarTapped, let titleTapped, let titleChanged): - let cell: SessionAvatarCell = tableView.dequeue(type: SessionAvatarCell.self, for: indexPath) - cell.update( - threadViewModel: threadViewModel, - style: style, - viewController: self - ) - cell.update(isEditing: self.isEditing, animated: false) - - cell.profilePictureTapPublisher - .filter { _ in threadViewModel.threadVariant == .contact } - .sink(receiveValue: { _ in avatarTapped?() }) - .store(in: &cell.disposables) - - cell.displayNameTapPublisher - .filter { _ in threadViewModel.threadVariant == .contact } - .sink(receiveValue: { _ in titleTapped?() }) - .store(in: &cell.disposables) - - cell.textPublisher - .sink(receiveValue: { text in titleChanged?(text) }) - .store(in: &cell.disposables) - - return cell - - default: - let cell: SessionCell = tableView.dequeue(type: SessionCell.self, for: indexPath) - cell.update( - with: info, - style: .rounded, - position: Position.with(indexPath.row, count: section.elements.count) - ) - cell.update(isEditing: self.isEditing, animated: false) - - return cell - } + return cell } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let section: SectionModel = viewModel.settingsData[section] + let section: SectionModel = viewModel.tableData[section] + let result: SessionHeaderView = tableView.dequeueHeaderFooterView(type: SessionHeaderView.self) + result.update( + title: section.model.title, + style: section.model.style + ) - switch section.model.style { - case .none: - return UIView() - - case .padding, .title: - let result: SessionHeaderView = tableView.dequeueHeaderFooterView(type: SessionHeaderView.self) - result.update( - title: section.model.title, - hasSeparator: (section.elements.first?.shouldHaveBackground != false) - ) - - return result - } + return result } // MARK: - UITableViewDelegate func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - let section: SectionModel = viewModel.settingsData[section] - - switch section.model.style { - case .none: return 0 - case .padding, .title: return UITableView.automaticDimension - } + return viewModel.tableData[section].model.style.height } func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { @@ -469,11 +531,28 @@ class SessionTableViewController CGFloat { return UITableView.automaticDimension } + + func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { + guard self.hasLoadedInitialTableData && self.viewHasAppeared && !self.isLoadingMore else { return } + + let section: SectionModel = self.viewModel.tableData[section] + + switch section.model.style { + case .loadMore: + self.isLoadingMore = true + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.viewModel.loadPageAfter() + } + + default: break + } + } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let section: SectionModel = self.viewModel.settingsData[indexPath.section] + let section: SectionModel = self.viewModel.tableData[indexPath.section] let info: SessionCell.Info = section.elements[indexPath.row] // Do nothing if the item is disabled @@ -486,10 +565,10 @@ class SessionTableViewController Void = { [weak self, weak tappedView] in - info.onTap?(tappedView) + info.onTap?() + info.onTapView?(tappedView) self?.manuallyReload(indexPath: indexPath, section: section, info: info) // Update the old selection as well @@ -535,10 +615,7 @@ class SessionTableViewController { typealias SectionModel = ArraySection> - typealias ObservableData = AnyPublisher<[SectionModel], Error> + typealias ObservableData = AnyPublisher<([SectionModel], StagedChangeset<[SectionModel]>), Error> // MARK: - Input - let navItemTapped: PassthroughSubject = PassthroughSubject() private let _isEditing: CurrentValueSubject = CurrentValueSubject(false) lazy var isEditing: AnyPublisher = _isEditing .removeDuplicates() .shareReplay(1) + private let _textChanged: PassthroughSubject<(text: String?, item: SettingItem), Never> = PassthroughSubject() + lazy var textChanged: AnyPublisher<(text: String?, item: SettingItem), Never> = _textChanged + .eraseToAnyPublisher() // MARK: - Navigation @@ -38,18 +40,25 @@ class SessionTableViewModel { Just(nil).eraseToAnyPublisher() } open var footerView: AnyPublisher { Just(nil).eraseToAnyPublisher() } open var footerButtonInfo: AnyPublisher { Just(nil).eraseToAnyPublisher() } - func updateSettings(_ updatedSettings: [SectionModel]) { + fileprivate var hasEmittedInitialData: Bool = false + public private(set) var tableData: [SectionModel] = [] + open var observableTableData: ObservableData { preconditionFailure("abstract class - override in subclass") } + open var pagedDataObserver: TransactionObserver? { nil } + + func updateTableData(_ updatedData: [SectionModel]) { + self.tableData = updatedData + } + + func loadPageBefore() { preconditionFailure("abstract class - override in subclass") } + func loadPageAfter() { preconditionFailure("abstract class - override in subclass") } // MARK: - Functions @@ -57,6 +66,10 @@ class SessionTableViewModel( + for viewModel: SessionTableViewModel? + ) -> [ArraySection>] where Element == ArraySection> { + // Update the data to include the proper position for each element + return self.map { section in + ArraySection( + model: section.model, + elements: section.elements.enumerated().map { index, element in + element.updatedPosition(for: index, count: section.elements.count) + } + ) + } + } +} + +extension AnyPublisher { + func mapToSessionTableViewData( + for viewModel: SessionTableViewModel + ) -> AnyPublisher<(Output, StagedChangeset), Failure> where Output == [ArraySection>] { + return self + .map { [weak viewModel] updatedData -> (Output, StagedChangeset) in + let updatedDataWithPositions: Output = updatedData + .mapToSessionTableViewData(for: viewModel) + + // Generate an updated changeset + let changeset = StagedChangeset( + source: (viewModel?.tableData ?? []), + target: updatedDataWithPositions + ) + + return (updatedDataWithPositions, changeset) + } + .filter { [weak viewModel] _, changeset in + viewModel?.hasEmittedInitialData == false || // Always emit at least once + !changeset.isEmpty // Do nothing if there were no changes + } + .handleEvents(receiveOutput: { [weak viewModel] _ in + viewModel?.hasEmittedInitialData = true + }) + .eraseToAnyPublisher() + } +} diff --git a/Session/Shared/Types/DismissType.swift b/Session/Shared/Types/DismissType.swift index 494813082..0f8dbcd1c 100644 --- a/Session/Shared/Types/DismissType.swift +++ b/Session/Shared/Types/DismissType.swift @@ -10,6 +10,9 @@ public enum DismissType { /// This will only trigger a `popViewController` call (if the screen was presented it'll do nothing) case pop + /// This will only trigger a `popToRootViewController` call (if the screen was presented it'll do nothing) + case popToRoot + /// This will only trigger a `dismiss` call (if the screen was pushed to a presented navigation controller it'll dismiss /// the navigation controller, otherwise this will do nothing) case dismiss diff --git a/Session/Shared/Types/SessionCell+Accessory.swift b/Session/Shared/Types/SessionCell+Accessory.swift index fb0f08fe8..af9d617eb 100644 --- a/Session/Shared/Types/SessionCell+Accessory.swift +++ b/Session/Shared/Types/SessionCell+Accessory.swift @@ -12,45 +12,77 @@ extension SessionCell { UIImage?, size: IconSize, customTint: ThemeValue?, - shouldFill: Bool + shouldFill: Bool, + accessibility: Accessibility? ) case iconAsync( size: IconSize, customTint: ThemeValue?, shouldFill: Bool, + accessibility: Accessibility?, setter: (UIImageView) -> Void ) - case toggle(DataSource) - case dropDown(DataSource) + case toggle( + DataSource, + accessibility: Accessibility? + ) + case dropDown( + DataSource, + accessibility: Accessibility? + ) case radio( size: RadioSize, isSelected: () -> Bool, - storedSelection: Bool + storedSelection: Bool, + accessibility: Accessibility? ) - case highlightingBackgroundLabel(title: String) - case profile(String, Profile?) - case customView(viewGenerator: () -> UIView) - case threadInfo( - threadViewModel: SessionThreadViewModel, - style: ThreadInfoStyle = ThreadInfoStyle(), - avatarTapped: (() -> Void)? = nil, - titleTapped: (() -> Void)? = nil, - titleChanged: ((String) -> Void)? = nil + case highlightingBackgroundLabel( + title: String, + accessibility: Accessibility? + ) + case profile( + id: String, + size: ProfilePictureView.Size, + threadVariant: SessionThread.Variant, + customImageData: Data?, + profile: Profile?, + profileIcon: ProfilePictureView.ProfileIcon, + additionalProfile: Profile?, + additionalProfileIcon: ProfilePictureView.ProfileIcon, + accessibility: Accessibility? + ) + + case search( + placeholder: String, + accessibility: Accessibility?, + searchTermChanged: (String?) -> Void + ) + case button( + style: SessionButton.Style, + title: String, + accessibility: Accessibility?, + run: (SessionButton?) -> Void + ) + case customView( + hashValue: AnyHashable, + viewGenerator: () -> UIView ) // MARK: - Convenience Vatiables var shouldFitToEdge: Bool { switch self { - case .icon(_, _, _, let shouldFill), .iconAsync(_, _, let shouldFill, _): return shouldFill + case .icon(_, _, _, let shouldFill, _), .iconAsync(_, _, let shouldFill, _, _): + return shouldFill default: return false } } var currentBoolValue: Bool { switch self { - case .toggle(let dataSource), .dropDown(let dataSource): return dataSource.currentBoolValue + case .toggle(let dataSource, _), .dropDown(let dataSource, _): return dataSource.currentBoolValue + case .radio(_, let isSelected, _, _): return isSelected() default: return false } } @@ -59,90 +91,171 @@ extension SessionCell { public func hash(into hasher: inout Hasher) { switch self { - case .icon(let image, let size, let customTint, let shouldFill): + case .icon(let image, let size, let customTint, let shouldFill, let accessibility): image.hash(into: &hasher) size.hash(into: &hasher) customTint.hash(into: &hasher) shouldFill.hash(into: &hasher) + accessibility.hash(into: &hasher) - case .iconAsync(let size, let customTint, let shouldFill, _): + case .iconAsync(let size, let customTint, let shouldFill, let accessibility, _): size.hash(into: &hasher) customTint.hash(into: &hasher) shouldFill.hash(into: &hasher) + accessibility.hash(into: &hasher) - case .toggle(let dataSource): + case .toggle(let dataSource, let accessibility): dataSource.hash(into: &hasher) + accessibility.hash(into: &hasher) - case .dropDown(let dataSource): + case .dropDown(let dataSource, let accessibility): dataSource.hash(into: &hasher) + accessibility.hash(into: &hasher) - case .radio(let size, let isSelected, let storedSelection): + case .radio(let size, let isSelected, let storedSelection, let accessibility): size.hash(into: &hasher) isSelected().hash(into: &hasher) storedSelection.hash(into: &hasher) + accessibility.hash(into: &hasher) - case .highlightingBackgroundLabel(let title): + case .highlightingBackgroundLabel(let title, let accessibility): title.hash(into: &hasher) + accessibility.hash(into: &hasher) - case .profile(let profileId, let profile): + case .profile( + let profileId, + let size, + let threadVariant, + let customImageData, + let profile, + let profileIcon, + let additionalProfile, + let additionalProfileIcon, + let accessibility + ): profileId.hash(into: &hasher) + size.hash(into: &hasher) + threadVariant.hash(into: &hasher) + customImageData.hash(into: &hasher) profile.hash(into: &hasher) + profileIcon.hash(into: &hasher) + additionalProfile.hash(into: &hasher) + additionalProfileIcon.hash(into: &hasher) + accessibility.hash(into: &hasher) - case .customView: break - - case .threadInfo(let threadViewModel, let style, _, _, _): - threadViewModel.hash(into: &hasher) + case .search(let placeholder, let accessibility, _): + placeholder.hash(into: &hasher) + accessibility.hash(into: &hasher) + + case .button(let style, let title, let accessibility, _): style.hash(into: &hasher) + title.hash(into: &hasher) + accessibility.hash(into: &hasher) + + case .customView(let hashValue, _): + hashValue.hash(into: &hasher) } } public static func == (lhs: Accessory, rhs: Accessory) -> Bool { switch (lhs, rhs) { - case (.icon(let lhsImage, let lhsSize, let lhsCustomTint, let lhsShouldFill), .icon(let rhsImage, let rhsSize, let rhsCustomTint, let rhsShouldFill)): + case (.icon(let lhsImage, let lhsSize, let lhsCustomTint, let lhsShouldFill, let lhsAccessibility), .icon(let rhsImage, let rhsSize, let rhsCustomTint, let rhsShouldFill, let rhsAccessibility)): return ( lhsImage == rhsImage && lhsSize == rhsSize && lhsCustomTint == rhsCustomTint && - lhsShouldFill == rhsShouldFill + lhsShouldFill == rhsShouldFill && + lhsAccessibility == rhsAccessibility ) - case (.iconAsync(let lhsSize, let lhsCustomTint, let lhsShouldFill, _), .iconAsync(let rhsSize, let rhsCustomTint, let rhsShouldFill, _)): + case (.iconAsync(let lhsSize, let lhsCustomTint, let lhsShouldFill, let lhsAccessibility, _), .iconAsync(let rhsSize, let rhsCustomTint, let rhsShouldFill, let rhsAccessibility, _)): return ( lhsSize == rhsSize && lhsCustomTint == rhsCustomTint && - lhsShouldFill == rhsShouldFill + lhsShouldFill == rhsShouldFill && + lhsAccessibility == rhsAccessibility ) - case (.toggle(let lhsDataSource), .toggle(let rhsDataSource)): - return (lhsDataSource == rhsDataSource) + case (.toggle(let lhsDataSource, let lhsAccessibility), .toggle(let rhsDataSource, let rhsAccessibility)): + return ( + lhsDataSource == rhsDataSource && + lhsAccessibility == rhsAccessibility + ) - case (.dropDown(let lhsDataSource), .dropDown(let rhsDataSource)): - return (lhsDataSource == rhsDataSource) + case (.dropDown(let lhsDataSource, let lhsAccessibility), .dropDown(let rhsDataSource, let rhsAccessibility)): + return ( + lhsDataSource == rhsDataSource && + lhsAccessibility == rhsAccessibility + ) - case (.radio(let lhsSize, let lhsIsSelected, let lhsStoredSelection), .radio(let rhsSize, let rhsIsSelected, let rhsStoredSelection)): + case (.radio(let lhsSize, let lhsIsSelected, let lhsStoredSelection, let lhsAccessibility), .radio(let rhsSize, let rhsIsSelected, let rhsStoredSelection, let rhsAccessibility)): return ( lhsSize == rhsSize && lhsIsSelected() == rhsIsSelected() && - lhsStoredSelection == rhsStoredSelection + lhsStoredSelection == rhsStoredSelection && + lhsAccessibility == rhsAccessibility ) - case (.highlightingBackgroundLabel(let lhsTitle), .highlightingBackgroundLabel(let rhsTitle)): - return (lhsTitle == rhsTitle) + case (.highlightingBackgroundLabel(let lhsTitle, let lhsAccessibility), .highlightingBackgroundLabel(let rhsTitle, let rhsAccessibility)): + return ( + lhsTitle == rhsTitle && + lhsAccessibility == rhsAccessibility + ) - case (.profile(let lhsProfileId, let lhsProfile), .profile(let rhsProfileId, let rhsProfile)): + case ( + .profile( + let lhsProfileId, + let lhsSize, + let lhsThreadVariant, + let lhsCustomImageData, + let lhsProfile, + let lhsProfileIcon, + let lhsAdditionalProfile, + let lhsAdditionalProfileIcon, + let lhsAccessibility + ), + .profile( + let rhsProfileId, + let rhsSize, + let rhsThreadVariant, + let rhsCustomImageData, + let rhsProfile, + let rhsProfileIcon, + let rhsAdditionalProfile, + let rhsAdditionalProfileIcon, + let rhsAccessibility + ) + ): return ( lhsProfileId == rhsProfileId && - lhsProfile == rhsProfile + lhsSize == rhsSize && + lhsThreadVariant == rhsThreadVariant && + lhsCustomImageData == rhsCustomImageData && + lhsProfile == rhsProfile && + lhsProfileIcon == rhsProfileIcon && + lhsAdditionalProfile == rhsAdditionalProfile && + lhsAdditionalProfileIcon == rhsAdditionalProfileIcon && + lhsAccessibility == rhsAccessibility ) - case (.customView, .customView): return false - - case (.threadInfo(let lhsThreadViewModel, let lhsStyle, _, _, _), .threadInfo(let rhsThreadViewModel, let rhsStyle, _, _, _)): + case (.search(let lhsPlaceholder, let lhsAccessibility, _), .search(let rhsPlaceholder, let rhsAccessibility, _)): return ( - lhsThreadViewModel == rhsThreadViewModel && - lhsStyle == rhsStyle + lhsPlaceholder == rhsPlaceholder && + lhsAccessibility == rhsAccessibility ) - + + case (.button(let lhsStyle, let lhsTitle, let lhsAccessibility, _), .button(let rhsStyle, let rhsTitle, let rhsAccessibility, _)): + return ( + lhsStyle == rhsStyle && + lhsTitle == rhsTitle && + lhsAccessibility == rhsAccessibility + ) + + case (.customView(let lhsHashValue, _), .customView(let rhsHashValue, _)): + return ( + lhsHashValue.hashValue == rhsHashValue.hashValue + ) + default: return false } } @@ -157,59 +270,123 @@ extension SessionCell.Accessory { // MARK: - .icon Variants public static func icon(_ image: UIImage?) -> SessionCell.Accessory { - return .icon(image, size: .medium, customTint: nil, shouldFill: false) + return .icon(image, size: .medium, customTint: nil, shouldFill: false, accessibility: nil) } public static func icon(_ image: UIImage?, customTint: ThemeValue) -> SessionCell.Accessory { - return .icon(image, size: .medium, customTint: customTint, shouldFill: false) + return .icon(image, size: .medium, customTint: customTint, shouldFill: false, accessibility: nil) } public static func icon(_ image: UIImage?, size: IconSize) -> SessionCell.Accessory { - return .icon(image, size: size, customTint: nil, shouldFill: false) + return .icon(image, size: size, customTint: nil, shouldFill: false, accessibility: nil) } public static func icon(_ image: UIImage?, size: IconSize, customTint: ThemeValue) -> SessionCell.Accessory { - return .icon(image, size: size, customTint: customTint, shouldFill: false) + return .icon(image, size: size, customTint: customTint, shouldFill: false, accessibility: nil) } public static func icon(_ image: UIImage?, shouldFill: Bool) -> SessionCell.Accessory { - return .icon(image, size: .medium, customTint: nil, shouldFill: shouldFill) + return .icon(image, size: .medium, customTint: nil, shouldFill: shouldFill, accessibility: nil) + } + + public static func icon(_ image: UIImage?, accessibility: Accessibility) -> SessionCell.Accessory { + return .icon(image, size: .medium, customTint: nil, shouldFill: false, accessibility: accessibility) } // MARK: - .iconAsync Variants public static func iconAsync(_ setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory { - return .iconAsync(size: .medium, customTint: nil, shouldFill: false, setter: setter) + return .iconAsync(size: .medium, customTint: nil, shouldFill: false, accessibility: nil, setter: setter) } public static func iconAsync(customTint: ThemeValue, _ setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory { - return .iconAsync(size: .medium, customTint: customTint, shouldFill: false, setter: setter) + return .iconAsync(size: .medium, customTint: customTint, shouldFill: false, accessibility: nil, setter: setter) } public static func iconAsync(size: IconSize, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory { - return .iconAsync(size: size, customTint: nil, shouldFill: false, setter: setter) + return .iconAsync(size: size, customTint: nil, shouldFill: false, accessibility: nil, setter: setter) } public static func iconAsync(shouldFill: Bool, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory { - return .iconAsync(size: .medium, customTint: nil, shouldFill: shouldFill, setter: setter) + return .iconAsync(size: .medium, customTint: nil, shouldFill: shouldFill, accessibility: nil, setter: setter) } public static func iconAsync(size: IconSize, customTint: ThemeValue, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory { - return .iconAsync(size: size, customTint: customTint, shouldFill: false, setter: setter) + return .iconAsync(size: size, customTint: customTint, shouldFill: false, accessibility: nil, setter: setter) } public static func iconAsync(size: IconSize, shouldFill: Bool, setter: @escaping (UIImageView) -> Void) -> SessionCell.Accessory { - return .iconAsync(size: size, customTint: nil, shouldFill: shouldFill, setter: setter) + return .iconAsync(size: size, customTint: nil, shouldFill: shouldFill, accessibility: nil, setter: setter) + } + + // MARK: - .toggle Variants + + public static func toggle(_ dataSource: DataSource) -> SessionCell.Accessory { + return .toggle(dataSource, accessibility: nil) + } + + // MARK: - .dropDown Variants + + public static func dropDown(_ dataSource: DataSource) -> SessionCell.Accessory { + return .dropDown(dataSource, accessibility: nil) } // MARK: - .radio Variants public static func radio(isSelected: @escaping () -> Bool) -> SessionCell.Accessory { - return .radio(size: .medium, isSelected: isSelected, storedSelection: false) + return .radio(size: .medium, isSelected: isSelected, storedSelection: false, accessibility: nil) } public static func radio(isSelected: @escaping () -> Bool, storedSelection: Bool) -> SessionCell.Accessory { - return .radio(size: .medium, isSelected: isSelected, storedSelection: storedSelection) + return .radio(size: .medium, isSelected: isSelected, storedSelection: storedSelection, accessibility: nil) + } + + // MARK: - .highlightingBackgroundLabel Variants + + public static func highlightingBackgroundLabel(title: String) -> SessionCell.Accessory { + return .highlightingBackgroundLabel(title: title, accessibility: nil) + } + + // MARK: - .profile Variants + + public static func profile(id: String, profile: Profile?) -> SessionCell.Accessory { + return .profile( + id: id, + size: .list, + threadVariant: .contact, + customImageData: nil, + profile: profile, + profileIcon: .none, + additionalProfile: nil, + additionalProfileIcon: .none, + accessibility: nil + ) + } + + public static func profile(id: String, size: ProfilePictureView.Size, profile: Profile?) -> SessionCell.Accessory { + return .profile( + id: id, + size: size, + threadVariant: .contact, + customImageData: nil, + profile: profile, + profileIcon: .none, + additionalProfile: nil, + additionalProfileIcon: .none, + accessibility: nil + ) + } + + // MARK: - .search Variants + + public static func search(placeholder: String, searchTermChanged: @escaping (String?) -> Void) -> SessionCell.Accessory { + return .search(placeholder: placeholder, accessibility: nil, searchTermChanged: searchTermChanged) + } + + // MARK: - .button Variants + + public static func button(style: SessionButton.Style, title: String, run: @escaping (SessionButton?) -> Void) -> SessionCell.Accessory { + return .button(style: style, title: title, accessibility: nil, run: run) } } @@ -293,42 +470,3 @@ extension SessionCell.Accessory { } } } - -// MARK: - SessionCell.Accessory.ThreadInfoStyle - -extension SessionCell.Accessory { - public struct ThreadInfoStyle: Hashable, Equatable { - public enum Style: Hashable, Equatable { - case small - case monoSmall - case monoLarge - } - - public struct Action: Hashable, Equatable { - let title: String - let run: (SessionButton?) -> () - - public func hash(into hasher: inout Hasher) { - title.hash(into: &hasher) - } - - public static func == (lhs: Action, rhs: Action) -> Bool { - return (lhs.title == rhs.title) - } - } - - public let separatorTitle: String? - public let descriptionStyle: Style - public let descriptionActions: [Action] - - public init( - separatorTitle: String? = nil, - descriptionStyle: Style = .monoSmall, - descriptionActions: [Action] = [] - ) { - self.separatorTitle = separatorTitle - self.descriptionStyle = descriptionStyle - self.descriptionActions = descriptionActions - } - } -} diff --git a/Session/Shared/Types/SessionCell+ExtraAction.swift b/Session/Shared/Types/SessionCell+ExtraAction.swift deleted file mode 100644 index 64ee0428c..000000000 --- a/Session/Shared/Types/SessionCell+ExtraAction.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation - -extension SessionCell { - struct ExtraAction: Hashable, Equatable { - let title: String - let onTap: (() -> Void) - - // MARK: - Conformance - - public func hash(into hasher: inout Hasher) { - title.hash(into: &hasher) - } - - static func == (lhs: SessionCell.ExtraAction, rhs: SessionCell.ExtraAction) -> Bool { - return (lhs.title == rhs.title) - } - } -} diff --git a/Session/Shared/Types/SessionCell+Info.swift b/Session/Shared/Types/SessionCell+Info.swift index e342f7789..35d63cb03 100644 --- a/Session/Shared/Types/SessionCell+Info.swift +++ b/Session/Shared/Types/SessionCell+Info.swift @@ -7,21 +7,17 @@ import SessionUIKit extension SessionCell { public struct Info: Equatable, Hashable, Differentiable { let id: ID + let position: Position let leftAccessory: SessionCell.Accessory? - let title: String - let subtitle: String? - let subtitleExtraViewGenerator: (() -> UIView)? - let tintColor: ThemeValue + let title: TextInfo? + let subtitle: TextInfo? let rightAccessory: SessionCell.Accessory? - let extraAction: SessionCell.ExtraAction? + let styling: StyleInfo let isEnabled: Bool - let shouldHaveBackground: Bool - let accessibilityIdentifier: String? - let accessibilityLabel: String? - let leftAccessoryAccessibilityLabel: String? - let rightAccessoryAccessibilityLabel: String? + let accessibility: Accessibility? let confirmationInfo: ConfirmationModal.Info? - let onTap: ((UIView?) -> Void)? + let onTap: (() -> Void)? + let onTapView: ((UIView?) -> Void)? var currentBoolValue: Bool { return ( @@ -34,74 +30,30 @@ extension SessionCell { init( id: ID, + position: Position = .individual, leftAccessory: SessionCell.Accessory? = nil, - title: String, - subtitle: String? = nil, - subtitleExtraViewGenerator: (() -> UIView)? = nil, - tintColor: ThemeValue = .textPrimary, + title: SessionCell.TextInfo? = nil, + subtitle: SessionCell.TextInfo? = nil, rightAccessory: SessionCell.Accessory? = nil, - extraAction: SessionCell.ExtraAction? = nil, + styling: StyleInfo = StyleInfo(), isEnabled: Bool = true, - shouldHaveBackground: Bool = true, - accessibilityIdentifier: String? = nil, - accessibilityLabel: String? = nil, - leftAccessoryAccessibilityLabel: String? = nil, - rightAccessoryAccessibilityLabel: String? = nil, + accessibility: Accessibility? = nil, confirmationInfo: ConfirmationModal.Info? = nil, - onTap: ((UIView?) -> Void)? + onTap: (() -> Void)? = nil, + onTapView: ((UIView?) -> Void)? = nil ) { self.id = id + self.position = position self.leftAccessory = leftAccessory self.title = title self.subtitle = subtitle - self.subtitleExtraViewGenerator = subtitleExtraViewGenerator - self.tintColor = tintColor self.rightAccessory = rightAccessory - self.extraAction = extraAction + self.styling = styling self.isEnabled = isEnabled - self.shouldHaveBackground = shouldHaveBackground - self.accessibilityIdentifier = accessibilityIdentifier - self.accessibilityLabel = accessibilityLabel - self.leftAccessoryAccessibilityLabel = leftAccessoryAccessibilityLabel - self.rightAccessoryAccessibilityLabel = rightAccessoryAccessibilityLabel + self.accessibility = accessibility self.confirmationInfo = confirmationInfo self.onTap = onTap - } - - init( - id: ID, - leftAccessory: SessionCell.Accessory? = nil, - title: String, - subtitle: String? = nil, - subtitleExtraViewGenerator: (() -> UIView)? = nil, - tintColor: ThemeValue = .textPrimary, - rightAccessory: SessionCell.Accessory? = nil, - extraAction: SessionCell.ExtraAction? = nil, - isEnabled: Bool = true, - shouldHaveBackground: Bool = true, - accessibilityIdentifier: String? = nil, - accessibilityLabel: String? = nil, - leftAccessoryAccessibilityLabel: String? = nil, - rightAccessoryAccessibilityLabel: String? = nil, - confirmationInfo: ConfirmationModal.Info? = nil, - onTap: (() -> Void)? = nil - ) { - self.id = id - self.leftAccessory = leftAccessory - self.title = title - self.subtitle = subtitle - self.subtitleExtraViewGenerator = subtitleExtraViewGenerator - self.tintColor = tintColor - self.rightAccessory = rightAccessory - self.extraAction = extraAction - self.isEnabled = isEnabled - self.shouldHaveBackground = shouldHaveBackground - self.accessibilityIdentifier = accessibilityIdentifier - self.accessibilityLabel = accessibilityLabel - self.leftAccessoryAccessibilityLabel = leftAccessoryAccessibilityLabel - self.rightAccessoryAccessibilityLabel = rightAccessoryAccessibilityLabel - self.confirmationInfo = confirmationInfo - self.onTap = (onTap != nil ? { _ in onTap?() } : nil) + self.onTapView = onTapView } // MARK: - Conformance @@ -110,37 +62,190 @@ extension SessionCell { public func hash(into hasher: inout Hasher) { id.hash(into: &hasher) + position.hash(into: &hasher) leftAccessory.hash(into: &hasher) title.hash(into: &hasher) subtitle.hash(into: &hasher) - tintColor.hash(into: &hasher) rightAccessory.hash(into: &hasher) - extraAction.hash(into: &hasher) + styling.hash(into: &hasher) isEnabled.hash(into: &hasher) - shouldHaveBackground.hash(into: &hasher) - accessibilityIdentifier.hash(into: &hasher) - accessibilityLabel.hash(into: &hasher) - leftAccessoryAccessibilityLabel.hash(into: &hasher) - rightAccessoryAccessibilityLabel.hash(into: &hasher) + accessibility.hash(into: &hasher) confirmationInfo.hash(into: &hasher) } public static func == (lhs: Info, rhs: Info) -> Bool { return ( lhs.id == rhs.id && + lhs.position == rhs.position && lhs.leftAccessory == rhs.leftAccessory && lhs.title == rhs.title && lhs.subtitle == rhs.subtitle && - lhs.tintColor == rhs.tintColor && lhs.rightAccessory == rhs.rightAccessory && - lhs.extraAction == rhs.extraAction && + lhs.styling == rhs.styling && lhs.isEnabled == rhs.isEnabled && - lhs.shouldHaveBackground == rhs.shouldHaveBackground && - lhs.accessibilityIdentifier == rhs.accessibilityIdentifier && - lhs.accessibilityLabel == rhs.accessibilityLabel && - lhs.leftAccessoryAccessibilityLabel == rhs.leftAccessoryAccessibilityLabel && - lhs.rightAccessoryAccessibilityLabel == rhs.rightAccessoryAccessibilityLabel + lhs.accessibility == rhs.accessibility + ) + } + + // MARK: - Convenience + + public func updatedPosition(for index: Int, count: Int) -> Info { + return Info( + id: id, + position: Position.with(index, count: count), + leftAccessory: leftAccessory, + title: title, + subtitle: subtitle, + rightAccessory: rightAccessory, + styling: styling, + isEnabled: isEnabled, + accessibility: accessibility, + confirmationInfo: confirmationInfo, + onTap: onTap, + onTapView: onTapView ) } } } + +// MARK: - Convenience Initializers + +public extension SessionCell.Info { + // Accessory, () -> Void + + init( + id: ID, + position: Position = .individual, + accessory: SessionCell.Accessory, + styling: SessionCell.StyleInfo = SessionCell.StyleInfo(), + isEnabled: Bool = true, + accessibility: Accessibility? = nil, + confirmationInfo: ConfirmationModal.Info? = nil, + onTap: (() -> Void)? = nil + ) { + self.id = id + self.position = position + self.leftAccessory = accessory + self.title = nil + self.subtitle = nil + self.rightAccessory = nil + self.styling = styling + self.isEnabled = isEnabled + self.accessibility = accessibility + self.confirmationInfo = confirmationInfo + self.onTap = onTap + self.onTapView = nil + } + + // leftAccessory, rightAccessory + + init( + id: ID, + position: Position = .individual, + leftAccessory: SessionCell.Accessory, + rightAccessory: SessionCell.Accessory, + styling: SessionCell.StyleInfo = SessionCell.StyleInfo(), + isEnabled: Bool = true, + accessibility: Accessibility? = nil, + confirmationInfo: ConfirmationModal.Info? = nil + ) { + self.id = id + self.position = position + self.leftAccessory = leftAccessory + self.title = nil + self.subtitle = nil + self.rightAccessory = rightAccessory + self.styling = styling + self.isEnabled = isEnabled + self.accessibility = accessibility + self.confirmationInfo = confirmationInfo + self.onTap = nil + self.onTapView = nil + } + + // String, () -> Void + + init( + id: ID, + position: Position = .individual, + leftAccessory: SessionCell.Accessory? = nil, + title: String, + rightAccessory: SessionCell.Accessory? = nil, + styling: SessionCell.StyleInfo = SessionCell.StyleInfo(), + isEnabled: Bool = true, + accessibility: Accessibility? = nil, + confirmationInfo: ConfirmationModal.Info? = nil, + onTap: (() -> Void)? = nil + ) { + self.id = id + self.position = position + self.leftAccessory = leftAccessory + self.title = SessionCell.TextInfo(title, font: .title) + self.subtitle = nil + self.rightAccessory = rightAccessory + self.styling = styling + self.isEnabled = isEnabled + self.accessibility = accessibility + self.confirmationInfo = confirmationInfo + self.onTap = onTap + self.onTapView = nil + } + + // TextInfo, () -> Void + + init( + id: ID, + position: Position = .individual, + leftAccessory: SessionCell.Accessory? = nil, + title: SessionCell.TextInfo, + rightAccessory: SessionCell.Accessory? = nil, + styling: SessionCell.StyleInfo = SessionCell.StyleInfo(), + isEnabled: Bool = true, + accessibility: Accessibility? = nil, + confirmationInfo: ConfirmationModal.Info? = nil, + onTap: (() -> Void)? = nil + ) { + self.id = id + self.position = position + self.leftAccessory = leftAccessory + self.title = title + self.subtitle = nil + self.rightAccessory = rightAccessory + self.styling = styling + self.isEnabled = isEnabled + self.accessibility = accessibility + self.confirmationInfo = confirmationInfo + self.onTap = onTap + self.onTapView = nil + } + + // String, String?, () -> Void + + init( + id: ID, + position: Position = .individual, + leftAccessory: SessionCell.Accessory? = nil, + title: String, + subtitle: String?, + rightAccessory: SessionCell.Accessory? = nil, + styling: SessionCell.StyleInfo = SessionCell.StyleInfo(), + isEnabled: Bool = true, + accessibility: Accessibility? = nil, + confirmationInfo: ConfirmationModal.Info? = nil, + onTap: (() -> Void)? = nil, + onTapView: ((UIView?) -> Void)? = nil + ) { + self.id = id + self.position = position + self.leftAccessory = leftAccessory + self.title = SessionCell.TextInfo(title, font: .title) + self.subtitle = SessionCell.TextInfo(subtitle, font: .subtitle) + self.rightAccessory = rightAccessory + self.styling = styling + self.isEnabled = isEnabled + self.accessibility = accessibility + self.confirmationInfo = confirmationInfo + self.onTap = onTap + self.onTapView = onTapView + } +} diff --git a/Session/Shared/Types/SessionCell+Styling.swift b/Session/Shared/Types/SessionCell+Styling.swift new file mode 100644 index 000000000..948cd1631 --- /dev/null +++ b/Session/Shared/Types/SessionCell+Styling.swift @@ -0,0 +1,170 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit + +// MARK: - Main Types + +public extension SessionCell { + struct TextInfo: Hashable, Equatable { + public enum Interaction: Hashable, Equatable { + case none + case editable + case copy + case alwaysEditing + } + + let text: String? + let textAlignment: NSTextAlignment + let editingPlaceholder: String? + let interaction: Interaction + let extraViewGenerator: (() -> UIView)? + + private let fontStyle: FontStyle + var font: UIFont { fontStyle.font } + + init( + _ text: String?, + font: FontStyle, + alignment: NSTextAlignment = .left, + editingPlaceholder: String? = nil, + interaction: Interaction = .none, + extraViewGenerator: (() -> UIView)? = nil + ) { + self.text = text + self.fontStyle = font + self.textAlignment = alignment + self.editingPlaceholder = editingPlaceholder + self.interaction = interaction + self.extraViewGenerator = extraViewGenerator + } + + // MARK: - Conformance + + public func hash(into hasher: inout Hasher) { + text.hash(into: &hasher) + fontStyle.hash(into: &hasher) + textAlignment.hash(into: &hasher) + interaction.hash(into: &hasher) + editingPlaceholder.hash(into: &hasher) + } + + public static func == (lhs: TextInfo, rhs: TextInfo) -> Bool { + return ( + lhs.text == rhs.text && + lhs.fontStyle == rhs.fontStyle && + lhs.textAlignment == rhs.textAlignment && + lhs.interaction == rhs.interaction && + lhs.editingPlaceholder == rhs.editingPlaceholder + ) + } + } + + struct StyleInfo: Equatable, Hashable { + let tintColor: ThemeValue + let alignment: SessionCell.Alignment + let allowedSeparators: Separators + let customPadding: Padding? + let backgroundStyle: SessionCell.BackgroundStyle + + public init( + tintColor: ThemeValue = .textPrimary, + alignment: SessionCell.Alignment = .leading, + allowedSeparators: Separators = [.top, .bottom], + customPadding: Padding? = nil, + backgroundStyle: SessionCell.BackgroundStyle = .rounded + ) { + self.tintColor = tintColor + self.alignment = alignment + self.allowedSeparators = allowedSeparators + self.customPadding = customPadding + self.backgroundStyle = backgroundStyle + } + } +} + +// MARK: - Child Types + +public extension SessionCell { + enum FontStyle: Hashable, Equatable { + case title + case titleLarge + + case subtitle + case subtitleBold + + case monoSmall + case monoLarge + + var font: UIFont { + switch self { + case .title: return .boldSystemFont(ofSize: 16) + case .titleLarge: return .systemFont(ofSize: Values.veryLargeFontSize, weight: .medium) + + case .subtitle: return .systemFont(ofSize: 14) + case .subtitleBold: return .boldSystemFont(ofSize: 14) + + case .monoSmall: return Fonts.spaceMono(ofSize: Values.smallFontSize) + case .monoLarge: return Fonts.spaceMono( + ofSize: (isIPhone5OrSmaller ? Values.mediumFontSize : Values.largeFontSize) + ) + } + } + } + + enum Alignment: Equatable, Hashable { + case leading + case centerHugging + } + + enum BackgroundStyle: Equatable, Hashable { + case rounded + case edgeToEdge + case noBackground + } + + struct Separators: OptionSet, Equatable, Hashable { + public let rawValue: Int8 + + public init(rawValue: Int8) { + self.rawValue = rawValue + } + + public static let top: Separators = Separators(rawValue: 1 << 0) + public static let bottom: Separators = Separators(rawValue: 1 << 1) + } + + struct Padding: Equatable, Hashable { + let top: CGFloat? + let leading: CGFloat? + let trailing: CGFloat? + let bottom: CGFloat? + let interItem: CGFloat? + + init( + top: CGFloat? = nil, + leading: CGFloat? = nil, + trailing: CGFloat? = nil, + bottom: CGFloat? = nil, + interItem: CGFloat? = nil + ) { + self.top = top + self.leading = leading + self.trailing = trailing + self.bottom = bottom + self.interItem = interItem + } + } +} + +// MARK: - ExpressibleByStringLiteral + +extension SessionCell.TextInfo: ExpressibleByStringLiteral, ExpressibleByExtendedGraphemeClusterLiteral, ExpressibleByUnicodeScalarLiteral { + public init(stringLiteral value: String) { + self = SessionCell.TextInfo(value, font: .title) + } + + public init(unicodeScalarLiteral value: Character) { + self = SessionCell.TextInfo(String(value), font: .title) + } +} diff --git a/Session/Shared/Types/SessionTableSection.swift b/Session/Shared/Types/SessionTableSection.swift index b0f117269..023e69f5f 100644 --- a/Session/Shared/Types/SessionTableSection.swift +++ b/Session/Shared/Types/SessionTableSection.swift @@ -2,6 +2,7 @@ import Foundation import DifferenceKit +import SessionUIKit protocol SessionTableSection: Differentiable { var title: String? { get } @@ -13,8 +14,36 @@ extension SessionTableSection { var style: SessionTableSectionStyle { .none } } -public enum SessionTableSectionStyle: Differentiable { +public enum SessionTableSectionStyle: Equatable, Hashable, Differentiable { case none - case title + case titleRoundedContent + case titleEdgeToEdgeContent + case titleNoBackgroundContent + case titleSeparator case padding + case loadMore + + var height: CGFloat { + switch self { + case .none: return 0 + case .titleRoundedContent, .titleEdgeToEdgeContent, .titleNoBackgroundContent: + return UITableView.automaticDimension + + case .titleSeparator: return Separator.height + case .padding: return Values.smallSpacing + case .loadMore: return 40 + } + } + + /// These values should always be consistent with the padding in `SessionCell` to ensure the text lines up + var edgePadding: CGFloat { + switch self { + case .titleRoundedContent, .titleNoBackgroundContent: + // Align to the start of the text in the cell + return (Values.largeSpacing + Values.mediumSpacing) + + case .titleEdgeToEdgeContent, .titleSeparator: return Values.largeSpacing + case .none, .padding, .loadMore: return 0 + } + } } diff --git a/Session/Shared/UserSelectionVC.swift b/Session/Shared/UserSelectionVC.swift index 0c920f101..8e823ffbe 100644 --- a/Session/Shared/UserSelectionVC.swift +++ b/Session/Shared/UserSelectionVC.swift @@ -68,15 +68,15 @@ final class UserSelectionVC: BaseVC, UITableViewDataSource, UITableViewDelegate cell.update( with: SessionCell.Info( id: profile, - leftAccessory: .profile(profile.id, profile), + position: Position.with(indexPath.row, count: users.count), + leftAccessory: .profile(id: profile.id, profile: profile), title: profile.displayName(), rightAccessory: .radio(isSelected: { [weak self] in self?.selectedUsers.contains(profile.id) == true }), - accessibilityIdentifier: "Contact" - ), - style: .edgeToEdge, - position: Position.with(indexPath.row, count: users.count) + styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), + accessibility: Accessibility(identifier: "Contact") + ) ) return cell diff --git a/Session/Shared/Views/SessionAvatarCell.swift b/Session/Shared/Views/SessionAvatarCell.swift deleted file mode 100644 index b7f827946..000000000 --- a/Session/Shared/Views/SessionAvatarCell.swift +++ /dev/null @@ -1,310 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import Combine -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit -import SignalUtilitiesKit - -class SessionAvatarCell: UITableViewCell { - var disposables: Set = Set() - private var originalInputValue: String? - - // 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() - } - - // MARK: - UI - - private let stackView: UIStackView = { - let stackView: UIStackView = UIStackView() - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .vertical - stackView.spacing = Values.mediumSpacing - stackView.alignment = .center - stackView.distribution = .equalSpacing - - let horizontalSpacing: CGFloat = (UIScreen.main.bounds.size.height < 568 ? - Values.largeSpacing : - Values.veryLargeSpacing - ) - stackView.layoutMargins = UIEdgeInsets( - top: Values.mediumSpacing, - leading: horizontalSpacing, - bottom: Values.largeSpacing, - trailing: horizontalSpacing - ) - stackView.isLayoutMarginsRelativeArrangement = true - - return stackView - }() - - fileprivate let profilePictureView: ProfilePictureView = { - let view: ProfilePictureView = ProfilePictureView() - view.accessibilityLabel = "Profile picture" - view.isAccessibilityElement = true - view.translatesAutoresizingMaskIntoConstraints = false - view.size = Values.largeProfilePictureSize - - return view - }() - - fileprivate let displayNameContainer: UIView = { - let view: UIView = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.accessibilityIdentifier = "Username" - view.accessibilityLabel = "Username" - view.isAccessibilityElement = true - - return view - }() - - private lazy var displayNameLabel: UILabel = { - let label: UILabel = UILabel() - label.isAccessibilityElement = true - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .ows_mediumFont(withSize: Values.veryLargeFontSize) - label.themeTextColor = .textPrimary - label.textAlignment = .center - label.lineBreakMode = .byTruncatingTail - label.numberOfLines = 0 - - return label - }() - - fileprivate let displayNameTextField: UITextField = { - let textField: TextField = TextField(placeholder: "Enter a name", usesDefaultHeight: false) - textField.translatesAutoresizingMaskIntoConstraints = false - textField.textAlignment = .center - textField.accessibilityIdentifier = "Nickname" - textField.accessibilityLabel = "Nickname" - textField.isAccessibilityElement = true - textField.alpha = 0 - - return textField - }() - - private let descriptionSeparator: Separator = { - let result: Separator = Separator() - result.isHidden = true - - return result - }() - - private let descriptionLabel: SRCopyableLabel = { - let label: SRCopyableLabel = SRCopyableLabel() - label.accessibilityLabel = "Session ID" - label.translatesAutoresizingMaskIntoConstraints = false - label.themeTextColor = .textPrimary - label.textAlignment = .center - label.lineBreakMode = .byCharWrapping - label.numberOfLines = 0 - - return label - }() - - private let descriptionActionStackView: UIStackView = { - let stackView: UIStackView = UIStackView() - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.axis = .horizontal - stackView.alignment = .center - stackView.distribution = .fillEqually - stackView.spacing = (UIDevice.current.isIPad ? Values.iPadButtonSpacing : Values.mediumSpacing) - - return stackView - }() - - private func setupViewHierarchy() { - self.themeBackgroundColor = nil - self.selectedBackgroundView = UIView() - - contentView.addSubview(stackView) - - stackView.addArrangedSubview(profilePictureView) - stackView.addArrangedSubview(displayNameContainer) - stackView.addArrangedSubview(descriptionSeparator) - stackView.addArrangedSubview(descriptionLabel) - stackView.addArrangedSubview(descriptionActionStackView) - - displayNameContainer.addSubview(displayNameLabel) - displayNameContainer.addSubview(displayNameTextField) - - setupLayout() - } - - // MARK: - Layout - - private func setupLayout() { - stackView.pin(to: contentView) - - profilePictureView.set(.width, to: profilePictureView.size) - profilePictureView.set(.height, to: profilePictureView.size) - - displayNameLabel.pin(to: displayNameContainer) - displayNameTextField.center(in: displayNameContainer) - displayNameTextField.widthAnchor - .constraint( - lessThanOrEqualTo: stackView.widthAnchor, - constant: -(stackView.layoutMargins.left + stackView.layoutMargins.right) - ) - .isActive = true - - descriptionSeparator.set( - .width, - to: .width, - of: stackView, - withOffset: -(stackView.layoutMargins.left + stackView.layoutMargins.right) - ) - descriptionActionStackView.set( - .width, - to: .width, - of: stackView, - withOffset: -(stackView.layoutMargins.left + stackView.layoutMargins.right) - ) - } - - // MARK: - Content - - override func prepareForReuse() { - super.prepareForReuse() - - self.disposables = Set() - self.originalInputValue = nil - self.displayNameLabel.text = nil - self.displayNameTextField.text = nil - self.descriptionLabel.font = .ows_lightFont(withSize: Values.smallFontSize) - self.descriptionLabel.text = nil - - self.descriptionSeparator.isHidden = true - self.descriptionActionStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - } - - func update( - threadViewModel: SessionThreadViewModel, - style: SessionCell.Accessory.ThreadInfoStyle, - viewController: UIViewController - ) { - profilePictureView.update( - publicKey: threadViewModel.threadId, - profile: threadViewModel.profile, - additionalProfile: threadViewModel.additionalProfile, - threadVariant: threadViewModel.threadVariant, - openGroupProfilePictureData: threadViewModel.openGroupProfilePictureData, - useFallbackPicture: ( - threadViewModel.threadVariant == .openGroup && - threadViewModel.openGroupProfilePictureData == nil - ), - showMultiAvatarForClosedGroup: true - ) - - originalInputValue = threadViewModel.profile?.nickname - displayNameLabel.text = { - guard !threadViewModel.threadIsNoteToSelf else { - guard let profile: Profile = threadViewModel.profile else { - return Profile.truncated(id: threadViewModel.threadId, truncating: .middle) - } - - return profile.displayName() - } - - return threadViewModel.displayName - }() - descriptionLabel.font = { - switch style.descriptionStyle { - case .small: return .ows_lightFont(withSize: Values.smallFontSize) - case .monoSmall: return Fonts.spaceMono(ofSize: Values.smallFontSize) - case .monoLarge: return Fonts.spaceMono( - ofSize: (isIPhone5OrSmaller ? Values.mediumFontSize : Values.largeFontSize) - ) - } - }() - descriptionLabel.text = threadViewModel.threadId - descriptionLabel.isHidden = (threadViewModel.threadVariant != .contact) - descriptionLabel.isUserInteractionEnabled = ( - threadViewModel.threadVariant == .contact || - threadViewModel.threadVariant == .openGroup - ) - displayNameTextField.text = threadViewModel.profile?.nickname - descriptionSeparator.update(title: style.separatorTitle) - descriptionSeparator.isHidden = (style.separatorTitle == nil) - - if (UIDevice.current.isIPad) { - descriptionActionStackView.addArrangedSubview(UIView.hStretchingSpacer()) - } - - style.descriptionActions.forEach { action in - let result: SessionButton = SessionButton(style: .bordered, size: .medium) - result.setTitle(action.title, for: UIControl.State.normal) - result.tapPublisher - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak result] _ in action.run(result) }) - .store(in: &self.disposables) - - descriptionActionStackView.addArrangedSubview(result) - } - - if (UIDevice.current.isIPad) { - descriptionActionStackView.addArrangedSubview(UIView.hStretchingSpacer()) - } - descriptionActionStackView.isHidden = style.descriptionActions.isEmpty - } - - func update(isEditing: Bool, animated: Bool) { - let changes = { [weak self] in - self?.displayNameLabel.alpha = (isEditing ? 0 : 1) - self?.displayNameTextField.alpha = (isEditing ? 1 : 0) - } - let completion: (Bool) -> Void = { [weak self] complete in - self?.displayNameTextField.text = self?.originalInputValue - self?.displayNameContainer.accessibilityLabel = self?.displayNameLabel.text - } - - if animated { - UIView.animate(withDuration: 0.25, animations: changes, completion: completion) - } - else { - changes() - completion(true) - } - - if isEditing { - displayNameTextField.becomeFirstResponder() - } - else { - displayNameTextField.resignFirstResponder() - } - } -} - -// MARK: - Compose - -extension CombineCompatible where Self: SessionAvatarCell { - var textPublisher: AnyPublisher { - return self.displayNameTextField.publisher(for: .editingChanged) - .map { textField -> String in (textField.text ?? "") } - .eraseToAnyPublisher() - } - - var displayNameTapPublisher: AnyPublisher { - return self.displayNameContainer.tapPublisher - .map { _ in () } - .eraseToAnyPublisher() - } - - var profilePictureTapPublisher: AnyPublisher { - return self.profilePictureView.tapPublisher - .map { _ in () } - .eraseToAnyPublisher() - } -} diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index 5b43c9cd0..44c81b9eb 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -7,15 +7,25 @@ import SessionUtilitiesKit import SignalUtilitiesKit extension SessionCell { - public class AccessoryView: UIView { + public class AccessoryView: UIView, UISearchBarDelegate { + // Note: We set a minimum width for the 'AccessoryView' so that the titles line up + // nicely when we have a mix of icons and switches + private static let minWidth: CGFloat = 50 + + private var onTap: ((SessionButton?) -> Void)? + private var searchTermChanged: ((String?) -> Void)? + // MARK: - UI + private lazy var minWidthConstraint: NSLayoutConstraint = self.widthAnchor + .constraint(greaterThanOrEqualToConstant: AccessoryView.minWidth) + private lazy var fixedWidthConstraint: NSLayoutConstraint = self.set(.width, to: AccessoryView.minWidth) private lazy var imageViewConstraints: [NSLayoutConstraint] = [ imageView.pin(.top, to: .top, of: self), - imageView.pin(.leading, to: .leading, of: self), - imageView.pin(.trailing, to: .trailing, of: self), imageView.pin(.bottom, to: .bottom, of: self) ] + private lazy var imageViewLeadingConstraint: NSLayoutConstraint = imageView.pin(.leading, to: .leading, of: self) + private lazy var imageViewTrailingConstraint: NSLayoutConstraint = imageView.pin(.trailing, to: .trailing, of: self) private lazy var imageViewWidthConstraint: NSLayoutConstraint = imageView.set(.width, to: 0) private lazy var imageViewHeightConstraint: NSLayoutConstraint = imageView.set(.height, to: 0) private lazy var toggleSwitchConstraints: [NSLayoutConstraint] = [ @@ -26,8 +36,8 @@ extension SessionCell { ] private lazy var dropDownStackViewConstraints: [NSLayoutConstraint] = [ dropDownStackView.pin(.top, to: .top, of: self), - dropDownStackView.pin(.leading, to: .leading, of: self), - dropDownStackView.pin(.trailing, to: .trailing, of: self), + dropDownStackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing), + dropDownStackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing), dropDownStackView.pin(.bottom, to: .bottom, of: self) ] private lazy var radioViewWidthConstraint: NSLayoutConstraint = radioView.set(.width, to: 0) @@ -36,14 +46,13 @@ extension SessionCell { private lazy var radioBorderViewHeightConstraint: NSLayoutConstraint = radioBorderView.set(.height, to: 0) private lazy var radioBorderViewConstraints: [NSLayoutConstraint] = [ radioBorderView.pin(.top, to: .top, of: self), - radioBorderView.pin(.leading, to: .leading, of: self), - radioBorderView.pin(.trailing, to: .trailing, of: self), + radioBorderView.center(.horizontal, in: self), radioBorderView.pin(.bottom, to: .bottom, of: self) ] private lazy var highlightingBackgroundLabelConstraints: [NSLayoutConstraint] = [ highlightingBackgroundLabel.pin(.top, to: .top, of: self), - highlightingBackgroundLabel.pin(.leading, to: .leading, of: self), - highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self), + highlightingBackgroundLabel.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing), + highlightingBackgroundLabel.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing), highlightingBackgroundLabel.pin(.bottom, to: .bottom, of: self) ] private lazy var profilePictureViewConstraints: [NSLayoutConstraint] = [ @@ -52,6 +61,18 @@ extension SessionCell { profilePictureView.pin(.trailing, to: .trailing, of: self), profilePictureView.pin(.bottom, to: .bottom, of: self) ] + private lazy var searchBarConstraints: [NSLayoutConstraint] = [ + searchBar.pin(.top, to: .top, of: self), + searchBar.pin(.leading, to: .leading, of: self, withInset: -8), // Removing default inset + searchBar.pin(.trailing, to: .trailing, of: self, withInset: 8), // Removing default inset + searchBar.pin(.bottom, to: .bottom, of: self) + ] + private lazy var buttonConstraints: [NSLayoutConstraint] = [ + button.pin(.top, to: .top, of: self), + button.pin(.leading, to: .leading, of: self), + button.pin(.trailing, to: .trailing, of: self), + button.pin(.bottom, to: .bottom, of: self) + ] private let imageView: UIImageView = { let result: UIImageView = UIImageView() @@ -141,12 +162,29 @@ extension SessionCell { }() private lazy var profilePictureView: ProfilePictureView = { - let result: ProfilePictureView = ProfilePictureView() + let result: ProfilePictureView = ProfilePictureView(size: .list) result.translatesAutoresizingMaskIntoConstraints = false - result.size = Values.smallProfilePictureSize result.isHidden = true - result.set(.width, to: Values.smallProfilePictureSize) - result.set(.height, to: Values.smallProfilePictureSize) + + return result + }() + + private lazy var searchBar: UISearchBar = { + let result: ContactsSearchBar = ContactsSearchBar() + result.themeTintColor = .textPrimary + result.themeBackgroundColor = .clear + result.searchTextField.themeBackgroundColor = .backgroundSecondary + result.delegate = self + result.isHidden = true + + return result + }() + + private lazy var button: SessionButton = { + let result: SessionButton = SessionButton(style: .bordered, size: .medium) + result.translatesAutoresizingMaskIntoConstraints = false + result.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + result.isHidden = true return result }() @@ -174,6 +212,8 @@ extension SessionCell { addSubview(radioBorderView) addSubview(highlightingBackgroundLabel) addSubview(profilePictureView) + addSubview(button) + addSubview(searchBar) dropDownStackView.addArrangedSubview(dropDownImageView) dropDownStackView.addArrangedSubview(dropDownLabel) @@ -185,7 +225,9 @@ extension SessionCell { // MARK: - Content func prepareForReuse() { - self.isHidden = true + isHidden = true + onTap = nil + searchTermChanged = nil imageView.image = nil imageView.themeTintColor = .textPrimary @@ -207,7 +249,15 @@ extension SessionCell { radioView.isHidden = true highlightingBackgroundLabel.isHidden = true profilePictureView.isHidden = true + button.isHidden = true + searchBar.isHidden = true + minWidthConstraint.constant = AccessoryView.minWidth + minWidthConstraint.isActive = false + fixedWidthConstraint.constant = AccessoryView.minWidth + fixedWidthConstraint.isActive = false + imageViewLeadingConstraint.isActive = false + imageViewTrailingConstraint.isActive = false imageViewWidthConstraint.isActive = false imageViewHeightConstraint.isActive = false imageViewConstraints.forEach { $0.isActive = false } @@ -220,13 +270,14 @@ extension SessionCell { radioBorderViewConstraints.forEach { $0.isActive = false } highlightingBackgroundLabelConstraints.forEach { $0.isActive = false } profilePictureViewConstraints.forEach { $0.isActive = false } + searchBarConstraints.forEach { $0.isActive = false } + buttonConstraints.forEach { $0.isActive = false } } public func update( with accessory: Accessory?, tintColor: ThemeValue, - isEnabled: Bool, - accessibilityLabel: String? + isEnabled: Bool ) { guard let accessory: Accessory = accessory else { return } @@ -234,8 +285,9 @@ extension SessionCell { self.isHidden = false switch accessory { - case .icon(let image, let iconSize, let customTint, let shouldFill): - imageView.accessibilityLabel = accessibilityLabel + case .icon(let image, let iconSize, let customTint, let shouldFill, let accessibility): + imageView.accessibilityIdentifier = accessibility?.identifier + imageView.accessibilityLabel = accessibility?.label imageView.image = image imageView.themeTintColor = (customTint ?? tintColor) imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit) @@ -244,21 +296,30 @@ extension SessionCell { switch iconSize { case .fit: imageView.sizeToFit() + fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2))) + fixedWidthConstraint.isActive = true imageViewWidthConstraint.constant = imageView.bounds.width imageViewHeightConstraint.constant = imageView.bounds.height default: + fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant) imageViewWidthConstraint.constant = iconSize.size imageViewHeightConstraint.constant = iconSize.size } + minWidthConstraint.isActive = !fixedWidthConstraint.isActive + imageViewLeadingConstraint.constant = (shouldFill ? 0 : Values.smallSpacing) + imageViewTrailingConstraint.constant = (shouldFill ? 0 : -Values.smallSpacing) + imageViewLeadingConstraint.isActive = true + imageViewTrailingConstraint.isActive = true imageViewWidthConstraint.isActive = true imageViewHeightConstraint.isActive = true imageViewConstraints.forEach { $0.isActive = true } - case .iconAsync(let iconSize, let customTint, let shouldFill, let setter): + case .iconAsync(let iconSize, let customTint, let shouldFill, let accessibility, let setter): setter(imageView) - imageView.accessibilityLabel = accessibilityLabel + imageView.accessibilityIdentifier = accessibility?.identifier + imageView.accessibilityLabel = accessibility?.label imageView.themeTintColor = (customTint ?? tintColor) imageView.contentMode = (shouldFill ? .scaleAspectFill : .scaleAspectFit) imageView.isHidden = false @@ -266,22 +327,33 @@ extension SessionCell { switch iconSize { case .fit: imageView.sizeToFit() + fixedWidthConstraint.constant = (imageView.bounds.width + (shouldFill ? 0 : (Values.smallSpacing * 2))) + fixedWidthConstraint.isActive = true imageViewWidthConstraint.constant = imageView.bounds.width imageViewHeightConstraint.constant = imageView.bounds.height default: + fixedWidthConstraint.isActive = (iconSize.size <= fixedWidthConstraint.constant) imageViewWidthConstraint.constant = iconSize.size imageViewHeightConstraint.constant = iconSize.size } + minWidthConstraint.isActive = !fixedWidthConstraint.isActive + imageViewLeadingConstraint.constant = (shouldFill ? 0 : Values.smallSpacing) + imageViewTrailingConstraint.constant = (shouldFill ? 0 : -Values.smallSpacing) + imageViewLeadingConstraint.isActive = true + imageViewTrailingConstraint.isActive = true imageViewWidthConstraint.isActive = true imageViewHeightConstraint.isActive = true imageViewConstraints.forEach { $0.isActive = true } - case .toggle(let dataSource): - toggleSwitch.accessibilityLabel = accessibilityLabel + case .toggle(let dataSource, let accessibility): + toggleSwitch.accessibilityIdentifier = accessibility?.identifier + toggleSwitch.accessibilityLabel = accessibility?.label toggleSwitch.isHidden = false toggleSwitch.isEnabled = isEnabled + + fixedWidthConstraint.isActive = true toggleSwitchConstraints.forEach { $0.isActive = true } let newValue: Bool = dataSource.currentBoolValue @@ -290,13 +362,15 @@ extension SessionCell { toggleSwitch.setOn(newValue, animated: true) } - case .dropDown(let dataSource): - dropDownLabel.accessibilityLabel = accessibilityLabel + case .dropDown(let dataSource, let accessibility): + dropDownLabel.accessibilityIdentifier = accessibility?.identifier + dropDownLabel.accessibilityLabel = accessibility?.label dropDownLabel.text = dataSource.currentStringValue dropDownStackView.isHidden = false dropDownStackViewConstraints.forEach { $0.isActive = true } + minWidthConstraint.isActive = true - case .radio(let size, let isSelectedRetriever, let storedSelection): + case .radio(let size, let isSelectedRetriever, let storedSelection, let accessibility): let isSelected: Bool = isSelectedRetriever() let wasOldSelection: Bool = (!isSelected && storedSelection) @@ -312,7 +386,8 @@ extension SessionCell { radioBorderView.layer.cornerRadius = (size.borderSize / 2) - radioView.accessibilityLabel = accessibilityLabel + radioView.accessibilityIdentifier = accessibility?.identifier + radioView.accessibilityLabel = accessibility?.label radioView.alpha = (wasOldSelection ? 0.3 : 1) radioView.isHidden = (!isSelected && !storedSelection) radioView.themeBackgroundColor = { @@ -335,32 +410,74 @@ extension SessionCell { radioBorderViewWidthConstraint.constant = size.borderSize radioBorderViewHeightConstraint.constant = size.borderSize + fixedWidthConstraint.isActive = true radioViewWidthConstraint.isActive = true radioViewHeightConstraint.isActive = true radioBorderViewWidthConstraint.isActive = true radioBorderViewHeightConstraint.isActive = true radioBorderViewConstraints.forEach { $0.isActive = true } - case .highlightingBackgroundLabel(let title): - highlightingBackgroundLabel.accessibilityLabel = accessibilityLabel + case .highlightingBackgroundLabel(let title, let accessibility): + highlightingBackgroundLabel.accessibilityIdentifier = accessibility?.identifier + highlightingBackgroundLabel.accessibilityLabel = accessibility?.label highlightingBackgroundLabel.text = title highlightingBackgroundLabel.themeTextColor = tintColor highlightingBackgroundLabel.isHidden = false highlightingBackgroundLabelConstraints.forEach { $0.isActive = true } + minWidthConstraint.isActive = true - case .profile(let profileId, let profile): - profilePictureView.accessibilityLabel = accessibilityLabel + case .profile( + let profileId, + let profileSize, + let threadVariant, + let customImageData, + let profile, + let profileIcon, + let additionalProfile, + let additionalProfileIcon, + let accessibility + ): + // Note: We MUST set the 'size' property before triggering the 'update' + // function or the profile picture won't layout correctly + profilePictureView.accessibilityIdentifier = accessibility?.identifier + profilePictureView.accessibilityLabel = accessibility?.label + profilePictureView.isAccessibilityElement = (accessibility != nil) + profilePictureView.size = profileSize profilePictureView.update( publicKey: profileId, + threadVariant: threadVariant, + customImageData: customImageData, profile: profile, - threadVariant: .contact + profileIcon: profileIcon, + additionalProfile: additionalProfile, + additionalProfileIcon: additionalProfileIcon ) profilePictureView.isHidden = false + + fixedWidthConstraint.constant = profileSize.viewSize + fixedWidthConstraint.isActive = true profilePictureViewConstraints.forEach { $0.isActive = true } - case .customView(let viewGenerator): + case .search(let placeholder, let accessibility, let searchTermChanged): + self.searchTermChanged = searchTermChanged + searchBar.accessibilityIdentifier = accessibility?.identifier + searchBar.accessibilityLabel = accessibility?.label + searchBar.placeholder = placeholder + searchBar.isHidden = false + searchBarConstraints.forEach { $0.isActive = true } + + case .button(let style, let title, let accessibility, let onTap): + self.onTap = onTap + button.accessibilityIdentifier = accessibility?.identifier + button.accessibilityLabel = accessibility?.label + button.setTitle(title, for: .normal) + button.setStyle(style) + button.isHidden = false + minWidthConstraint.isActive = true + buttonConstraints.forEach { $0.isActive = true } + + case .customView(_, let viewGenerator): let generatedView: UIView = viewGenerator() - generatedView.accessibilityLabel = accessibilityLabel addSubview(generatedView) generatedView.pin(.top, to: .top, of: self) @@ -368,10 +485,9 @@ extension SessionCell { generatedView.pin(.trailing, to: .trailing, of: self) generatedView.pin(.bottom, to: .bottom, of: self) - self.customView?.removeFromSuperview() // Just in case - self.customView = generatedView - - case .threadInfo: break + customView?.removeFromSuperview() // Just in case + customView = generatedView + minWidthConstraint.isActive = true } } @@ -384,6 +500,27 @@ extension SessionCell { func setSelected(_ selected: Bool, animated: Bool) { highlightingBackgroundLabel.setSelected(selected, animated: animated) } + + @objc private func buttonTapped() { + onTap?(button) + } + + // MARK: - UISearchBarDelegate + + public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + searchTermChanged?(searchText) + } + + public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(true, animated: true) + } + + public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(false, animated: true) + } + + public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.endEditing(true) + } } - } diff --git a/Session/Shared/Views/SessionCell.swift b/Session/Shared/Views/SessionCell.swift index e6fa6c033..912fb37a9 100644 --- a/Session/Shared/Views/SessionCell.swift +++ b/Session/Shared/Views/SessionCell.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import GRDB import DifferenceKit import SessionUIKit @@ -9,17 +10,13 @@ import SessionUtilitiesKit public class SessionCell: UITableViewCell { public static let cornerRadius: CGFloat = 17 - public enum Style { - case rounded - case roundedEdgeToEdge - case edgeToEdge - } - - /// This value is here to allow the theming update callback to be released when preparing for reuse - private var instanceView: UIView = UIView() - private var position: Position? + private var isEditingTitle = false + public private(set) var interactionMode: SessionCell.TextInfo.Interaction = .none + private var shouldHighlightTitle: Bool = true + private var originalInputValue: String? + private var titleExtraView: UIView? private var subtitleExtraView: UIView? - private var onExtraActionTap: (() -> Void)? + var disposables: Set = Set() // MARK: - UI @@ -29,8 +26,18 @@ public class SessionCell: UITableViewCell { private var topSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint() private var botSeparatorLeftConstraint: NSLayoutConstraint = NSLayoutConstraint() private var botSeparatorRightConstraint: NSLayoutConstraint = NSLayoutConstraint() + private lazy var contentStackViewTopConstraint: NSLayoutConstraint = contentStackView.pin(.top, to: .top, of: cellBackgroundView) + private lazy var contentStackViewLeadingConstraint: NSLayoutConstraint = contentStackView.pin(.leading, to: .leading, of: cellBackgroundView) + private lazy var contentStackViewTrailingConstraint: NSLayoutConstraint = contentStackView.pin(.trailing, to: .trailing, of: cellBackgroundView) + private lazy var contentStackViewBottomConstraint: NSLayoutConstraint = contentStackView.pin(.bottom, to: .bottom, of: cellBackgroundView) + private lazy var contentStackViewHorizontalCenterConstraint: NSLayoutConstraint = contentStackView.center(.horizontal, in: cellBackgroundView) private lazy var leftAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: leftAccessoryView) + private lazy var titleTextFieldLeadingConstraint: NSLayoutConstraint = titleTextField.pin(.leading, to: .leading, of: cellBackgroundView) + private lazy var titleTextFieldTrailingConstraint: NSLayoutConstraint = titleTextField.pin(.trailing, to: .trailing, of: cellBackgroundView) + private lazy var titleMinHeightConstraint: NSLayoutConstraint = titleStackView.heightAnchor + .constraint(greaterThanOrEqualTo: titleTextField.heightAnchor) private lazy var rightAccessoryFillConstraint: NSLayoutConstraint = contentStackView.set(.height, to: .height, of: rightAccessoryView) + private lazy var accessoryWidthMatchConstraint: NSLayoutConstraint = leftAccessoryView.set(.width, to: .width, of: rightAccessoryView) private let cellBackgroundView: UIView = { let result: UIView = UIView() @@ -65,7 +72,6 @@ public class SessionCell: UITableViewCell { result.distribution = .fill result.alignment = .center result.spacing = Values.mediumSpacing - result.isLayoutMarginsRelativeArrangement = true return result }() @@ -89,10 +95,10 @@ public class SessionCell: UITableViewCell { return result }() - private let titleLabel: UILabel = { - let result: UILabel = UILabel() + fileprivate let titleLabel: SRCopyableLabel = { + let result: SRCopyableLabel = SRCopyableLabel() result.translatesAutoresizingMaskIntoConstraints = false - result.font = .boldSystemFont(ofSize: 15) + result.isUserInteractionEnabled = false result.themeTextColor = .textPrimary result.numberOfLines = 0 result.setCompressionResistanceHorizontalLow() @@ -101,10 +107,21 @@ public class SessionCell: UITableViewCell { return result }() - private let subtitleLabel: UILabel = { - let result: UILabel = UILabel() + fileprivate let titleTextField: UITextField = { + let textField: TextField = TextField(placeholder: "", usesDefaultHeight: false) + textField.translatesAutoresizingMaskIntoConstraints = false + textField.textAlignment = .center + textField.alpha = 0 + textField.isHidden = true + textField.set(.height, to: Values.largeButtonHeight) + + return textField + }() + + private let subtitleLabel: SRCopyableLabel = { + let result: SRCopyableLabel = SRCopyableLabel() result.translatesAutoresizingMaskIntoConstraints = false - result.font = .systemFont(ofSize: 13) + result.isUserInteractionEnabled = false result.themeTextColor = .textPrimary result.numberOfLines = 0 result.isHidden = true @@ -114,33 +131,6 @@ public class SessionCell: UITableViewCell { return result }() - private lazy var extraActionTopSpacingView: UIView = UIView.spacer(withHeight: Values.smallSpacing) - - private lazy var extraActionButton: UIButton = { - let result: UIButton = UIButton() - result.translatesAutoresizingMaskIntoConstraints = false - result.titleLabel?.font = .boldSystemFont(ofSize: Values.smallFontSize) - result.titleLabel?.numberOfLines = 0 - result.contentHorizontalAlignment = .left - result.contentEdgeInsets = UIEdgeInsets( - top: 8, - left: 0, - bottom: 0, - right: 0 - ) - result.addTarget(self, action: #selector(extraActionTapped), for: .touchUpInside) - result.isHidden = true - - ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in - switch theme.interfaceStyle { - case .light: result?.setThemeTitleColor(.textPrimary, for: .normal) - default: result?.setThemeTitleColor(.primary, for: .normal) - } - } - - return result - }() - public let rightAccessoryView: AccessoryView = { let result: AccessoryView = AccessoryView() result.isHidden = true @@ -186,8 +176,8 @@ public class SessionCell: UITableViewCell { titleStackView.addArrangedSubview(titleLabel) titleStackView.addArrangedSubview(subtitleLabel) - titleStackView.addArrangedSubview(extraActionTopSpacingView) - titleStackView.addArrangedSubview(extraActionButton) + + cellBackgroundView.addSubview(titleTextField) setupLayout() } @@ -204,7 +194,10 @@ public class SessionCell: UITableViewCell { topSeparatorLeftConstraint = topSeparator.pin(.left, to: .left, of: cellBackgroundView) topSeparatorRightConstraint = topSeparator.pin(.right, to: .right, of: cellBackgroundView) - contentStackView.pin(to: cellBackgroundView) + contentStackViewTopConstraint.isActive = true + contentStackViewBottomConstraint.isActive = true + + titleTextField.center(.vertical, in: titleLabel) botSeparatorLeftConstraint = botSeparator.pin(.left, to: .left, of: cellBackgroundView) botSeparatorRightConstraint = botSeparator.pin(.right, to: .right, of: cellBackgroundView) @@ -217,55 +210,59 @@ public class SessionCell: UITableViewCell { // Need to force the contentStackView to layout if needed as it might not have updated it's // sizing yet self.contentStackView.layoutIfNeeded() + repositionExtraView(titleExtraView, for: titleLabel) + repositionExtraView(subtitleExtraView, for: subtitleLabel) + } + + private func repositionExtraView(_ targetView: UIView?, for label: UILabel) { + guard + let targetView: UIView = targetView, + let content: String = label.text, + let font: UIFont = label.font + else { return } - // Position the 'subtitleExtraView' at the end of the last line of text - if - let subtitleExtraView: UIView = self.subtitleExtraView, - let subtitle: String = subtitleLabel.text, - let font: UIFont = subtitleLabel.font - { - let layoutManager: NSLayoutManager = NSLayoutManager() - let textStorage = NSTextStorage( - attributedString: NSAttributedString( - string: subtitle, - attributes: [ .font: font ] - ) + // Position the 'targetView' at the end of the last line of text + let layoutManager: NSLayoutManager = NSLayoutManager() + let textStorage = NSTextStorage( + attributedString: NSAttributedString( + string: content, + attributes: [ .font: font ] ) - textStorage.addLayoutManager(layoutManager) - - let textContainer: NSTextContainer = NSTextContainer( - size: CGSize( - width: subtitleLabel.bounds.size.width, - height: 999 - ) + ) + textStorage.addLayoutManager(layoutManager) + + let textContainer: NSTextContainer = NSTextContainer( + size: CGSize( + width: label.bounds.size.width, + height: 999 ) - textContainer.lineFragmentPadding = 0 - layoutManager.addTextContainer(textContainer) - - var glyphRange: NSRange = NSRange() - layoutManager.characterRange( - forGlyphRange: NSRange(location: subtitle.glyphCount - 1, length: 1), - actualGlyphRange: &glyphRange - ) - let lastGlyphRect: CGRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) - - // Remove and re-add the 'subtitleExtraView' to clear any old constraints - subtitleExtraView.removeFromSuperview() - contentView.addSubview(subtitleExtraView) - - subtitleExtraView.pin( - .top, - to: .top, - of: subtitleLabel, - withInset: (lastGlyphRect.minY + ((lastGlyphRect.height / 2) - (subtitleExtraView.bounds.height / 2))) - ) - subtitleExtraView.pin( - .leading, - to: .leading, - of: subtitleLabel, - withInset: lastGlyphRect.maxX - ) - } + ) + textContainer.lineFragmentPadding = 0 + layoutManager.addTextContainer(textContainer) + + var glyphRange: NSRange = NSRange() + layoutManager.characterRange( + forGlyphRange: NSRange(location: content.glyphCount - 1, length: 1), + actualGlyphRange: &glyphRange + ) + let lastGlyphRect: CGRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + + // Remove and re-add the 'subtitleExtraView' to clear any old constraints + targetView.removeFromSuperview() + contentView.addSubview(targetView) + + targetView.pin( + .top, + to: .top, + of: label, + withInset: (lastGlyphRect.minY + ((lastGlyphRect.height / 2) - (targetView.bounds.height / 2))) + ) + targetView.pin( + .leading, + to: .leading, + of: label, + withInset: lastGlyphRect.maxX + ) } // MARK: - Content @@ -273,108 +270,189 @@ public class SessionCell: UITableViewCell { public override func prepareForReuse() { super.prepareForReuse() - self.instanceView = UIView() - self.position = nil - self.onExtraActionTap = nil - self.accessibilityIdentifier = nil + isEditingTitle = false + interactionMode = .none + shouldHighlightTitle = true + accessibilityIdentifier = nil + accessibilityLabel = nil + isAccessibilityElement = false + originalInputValue = nil + titleExtraView?.removeFromSuperview() + titleExtraView = nil + subtitleExtraView?.removeFromSuperview() + subtitleExtraView = nil + disposables = Set() + contentStackView.spacing = Values.mediumSpacing + contentStackViewLeadingConstraint.isActive = false + contentStackViewTrailingConstraint.isActive = false + contentStackViewHorizontalCenterConstraint.isActive = false + titleMinHeightConstraint.isActive = false leftAccessoryView.prepareForReuse() + leftAccessoryView.alpha = 1 leftAccessoryFillConstraint.isActive = false titleLabel.text = "" + titleLabel.textAlignment = .left titleLabel.themeTextColor = .textPrimary + titleLabel.alpha = 1 + titleTextField.text = "" + titleTextField.textAlignment = .center + titleTextField.themeTextColor = .textPrimary + titleTextField.isHidden = true + titleTextField.alpha = 0 + subtitleLabel.isUserInteractionEnabled = false subtitleLabel.text = "" subtitleLabel.themeTextColor = .textPrimary rightAccessoryView.prepareForReuse() + rightAccessoryView.alpha = 1 rightAccessoryFillConstraint.isActive = false + accessoryWidthMatchConstraint.isActive = false topSeparator.isHidden = true subtitleLabel.isHidden = true - extraActionTopSpacingView.isHidden = true - extraActionButton.setTitle("", for: .normal) - extraActionButton.isHidden = true botSeparator.isHidden = true - - subtitleExtraView?.removeFromSuperview() - subtitleExtraView = nil } - public func update( - with info: Info, - style: Style, - position: Position - ) { - self.instanceView = UIView() - self.position = position - self.subtitleExtraView = info.subtitleExtraViewGenerator?() - self.onExtraActionTap = info.extraAction?.onTap - self.accessibilityIdentifier = info.accessibilityIdentifier - self.accessibilityLabel = info.accessibilityLabel - self.isAccessibilityElement = true + public func update(with info: Info) { + interactionMode = (info.title?.interaction ?? .none) + shouldHighlightTitle = (info.title?.interaction != .copy) + titleExtraView = info.title?.extraViewGenerator?() + subtitleExtraView = info.subtitle?.extraViewGenerator?() + accessibilityIdentifier = info.accessibility?.identifier + accessibilityLabel = info.accessibility?.label + isAccessibilityElement = true + originalInputValue = info.title?.text + // Convenience Flags let leftFitToEdge: Bool = (info.leftAccessory?.shouldFitToEdge == true) let rightFitToEdge: Bool = (!leftFitToEdge && info.rightAccessory?.shouldFitToEdge == true) - leftAccessoryFillConstraint.isActive = leftFitToEdge + + // Content + contentStackView.spacing = (info.styling.customPadding?.interItem ?? Values.mediumSpacing) leftAccessoryView.update( with: info.leftAccessory, - tintColor: info.tintColor, - isEnabled: info.isEnabled, - accessibilityLabel: info.leftAccessoryAccessibilityLabel + tintColor: info.styling.tintColor, + isEnabled: info.isEnabled ) + titleStackView.isHidden = (info.title == nil && info.subtitle == nil) + titleLabel.isUserInteractionEnabled = (info.title?.interaction == .copy) + titleLabel.font = info.title?.font + titleLabel.text = info.title?.text + titleLabel.themeTextColor = info.styling.tintColor + titleLabel.textAlignment = (info.title?.textAlignment ?? .left) + titleLabel.isHidden = (info.title == nil) + titleTextField.text = info.title?.text + titleTextField.textAlignment = (info.title?.textAlignment ?? .left) + titleTextField.placeholder = info.title?.editingPlaceholder + titleTextField.isHidden = (info.title == nil) + titleTextField.accessibilityIdentifier = info.accessibility?.identifier + titleTextField.accessibilityLabel = info.accessibility?.label + subtitleLabel.isUserInteractionEnabled = (info.subtitle?.interaction == .copy) + subtitleLabel.font = info.subtitle?.font + subtitleLabel.text = info.subtitle?.text + subtitleLabel.themeTextColor = info.styling.tintColor + subtitleLabel.textAlignment = (info.subtitle?.textAlignment ?? .left) + subtitleLabel.isHidden = (info.subtitle == nil) rightAccessoryView.update( with: info.rightAccessory, - tintColor: info.tintColor, - isEnabled: info.isEnabled, - accessibilityLabel: info.rightAccessoryAccessibilityLabel - ) - rightAccessoryFillConstraint.isActive = rightFitToEdge - contentStackView.layoutMargins = UIEdgeInsets( - top: (leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing), - left: (leftFitToEdge ? 0 : Values.largeSpacing), - bottom: (leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing), - right: (rightFitToEdge ? 0 : Values.largeSpacing) + tintColor: info.styling.tintColor, + isEnabled: info.isEnabled ) - titleLabel.text = info.title - titleLabel.themeTextColor = info.tintColor - subtitleLabel.text = info.subtitle - subtitleLabel.themeTextColor = info.tintColor - subtitleLabel.isHidden = (info.subtitle == nil) - extraActionTopSpacingView.isHidden = (info.extraAction == nil) - extraActionButton.setTitle(info.extraAction?.title, for: .normal) - extraActionButton.isHidden = (info.extraAction == nil) + contentStackViewLeadingConstraint.isActive = (info.styling.alignment == .leading) + contentStackViewTrailingConstraint.isActive = (info.styling.alignment == .leading) + contentStackViewHorizontalCenterConstraint.constant = ((info.styling.customPadding?.leading ?? 0) + (info.styling.customPadding?.trailing ?? 0)) + contentStackViewHorizontalCenterConstraint.isActive = (info.styling.alignment == .centerHugging) + leftAccessoryFillConstraint.isActive = leftFitToEdge + rightAccessoryFillConstraint.isActive = rightFitToEdge + accessoryWidthMatchConstraint.isActive = { + switch (info.leftAccessory, info.rightAccessory) { + case (.button, .button): return true + default: return false + } + }() + titleLabel.setContentHuggingPriority( + (info.rightAccessory != nil ? .defaultLow : .required), + for: .horizontal + ) + titleLabel.setContentCompressionResistancePriority( + (info.rightAccessory != nil ? .defaultLow : .required), + for: .horizontal + ) + contentStackViewTopConstraint.constant = { + if let customPadding: CGFloat = info.styling.customPadding?.top { + return customPadding + } + + return (leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing) + }() + contentStackViewLeadingConstraint.constant = { + if let customPadding: CGFloat = info.styling.customPadding?.leading { + return customPadding + } + + return (leftFitToEdge ? 0 : Values.mediumSpacing) + }() + contentStackViewTrailingConstraint.constant = { + if let customPadding: CGFloat = info.styling.customPadding?.trailing { + return -customPadding + } + + return -(rightFitToEdge ? 0 : Values.mediumSpacing) + }() + contentStackViewBottomConstraint.constant = { + if let customPadding: CGFloat = info.styling.customPadding?.bottom { + return -customPadding + } + + return -(leftFitToEdge || rightFitToEdge ? 0 : Values.mediumSpacing) + }() + titleTextFieldLeadingConstraint.constant = { + guard info.styling.backgroundStyle != .noBackground else { return 0 } + + return (leftFitToEdge ? 0 : Values.mediumSpacing) + }() + titleTextFieldTrailingConstraint.constant = { + guard info.styling.backgroundStyle != .noBackground else { return 0 } + + return -(rightFitToEdge ? 0 : Values.mediumSpacing) + }() // Styling and positioning let defaultEdgePadding: CGFloat - cellBackgroundView.themeBackgroundColor = (info.shouldHaveBackground ? - .settings_tabBackground : - nil - ) - cellSelectedBackgroundView.isHidden = (!info.isEnabled || !info.shouldHaveBackground) - switch style { + switch info.styling.backgroundStyle { case .rounded: + cellBackgroundView.themeBackgroundColor = .settings_tabBackground + cellSelectedBackgroundView.isHidden = !info.isEnabled + defaultEdgePadding = Values.mediumSpacing backgroundLeftConstraint.constant = Values.largeSpacing backgroundRightConstraint.constant = -Values.largeSpacing cellBackgroundView.layer.cornerRadius = SessionCell.cornerRadius case .edgeToEdge: + cellBackgroundView.themeBackgroundColor = .settings_tabBackground + cellSelectedBackgroundView.isHidden = !info.isEnabled + defaultEdgePadding = 0 backgroundLeftConstraint.constant = 0 backgroundRightConstraint.constant = 0 cellBackgroundView.layer.cornerRadius = 0 - case .roundedEdgeToEdge: + case .noBackground: defaultEdgePadding = Values.mediumSpacing - backgroundLeftConstraint.constant = 0 - backgroundRightConstraint.constant = 0 - cellBackgroundView.layer.cornerRadius = SessionCell.cornerRadius + backgroundLeftConstraint.constant = Values.largeSpacing + backgroundRightConstraint.constant = -Values.largeSpacing + cellBackgroundView.themeBackgroundColor = nil + cellBackgroundView.layer.cornerRadius = 0 + cellSelectedBackgroundView.isHidden = true } let fittedEdgePadding: CGFloat = { func targetSize(accessory: Accessory?) -> CGFloat { switch accessory { - case .icon(_, let iconSize, _, _), .iconAsync(let iconSize, _, _, _): + case .icon(_, let iconSize, _, _, _), .iconAsync(let iconSize, _, _, _, _): return iconSize.size default: return defaultEdgePadding @@ -394,43 +472,103 @@ public class SessionCell: UITableViewCell { botSeparatorLeftConstraint.constant = (leftFitToEdge ? fittedEdgePadding : defaultEdgePadding) botSeparatorRightConstraint.constant = (rightFitToEdge ? -fittedEdgePadding : -defaultEdgePadding) - switch position { + switch info.position { case .top: cellBackgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - topSeparator.isHidden = (style != .edgeToEdge) - botSeparator.isHidden = false + topSeparator.isHidden = ( + !info.styling.allowedSeparators.contains(.top) || + info.styling.backgroundStyle != .edgeToEdge + ) + botSeparator.isHidden = ( + !info.styling.allowedSeparators.contains(.bottom) || + info.styling.backgroundStyle == .noBackground + ) case .middle: cellBackgroundView.layer.maskedCorners = [] topSeparator.isHidden = true - botSeparator.isHidden = false + botSeparator.isHidden = ( + !info.styling.allowedSeparators.contains(.bottom) || + info.styling.backgroundStyle == .noBackground + ) case .bottom: cellBackgroundView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] - topSeparator.isHidden = false - botSeparator.isHidden = (style != .edgeToEdge) + topSeparator.isHidden = true + botSeparator.isHidden = ( + !info.styling.allowedSeparators.contains(.bottom) || + info.styling.backgroundStyle != .edgeToEdge + ) case .individual: cellBackgroundView.layer.maskedCorners = [ .layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMinXMaxYCorner, .layerMaxXMaxYCorner ] - topSeparator.isHidden = (style != .edgeToEdge) - botSeparator.isHidden = (style != .edgeToEdge) + topSeparator.isHidden = ( + !info.styling.allowedSeparators.contains(.top) || + info.styling.backgroundStyle != .edgeToEdge + ) + botSeparator.isHidden = ( + !info.styling.allowedSeparators.contains(.bottom) || + info.styling.backgroundStyle != .edgeToEdge + ) } } - public func update(isEditing: Bool, animated: Bool) {} + public func update(isEditing: Bool, becomeFirstResponder: Bool, animated: Bool) { + // Note: We set 'isUserInteractionEnabled' based on the 'info.isEditable' flag + // so can use that to determine whether this element can become editable + guard interactionMode == .editable || interactionMode == .alwaysEditing else { return } + + self.isEditingTitle = isEditing + + let changes = { [weak self] in + self?.titleLabel.alpha = (isEditing ? 0 : 1) + self?.titleTextField.alpha = (isEditing ? 1 : 0) + self?.leftAccessoryView.alpha = (isEditing ? 0 : 1) + self?.rightAccessoryView.alpha = (isEditing ? 0 : 1) + self?.titleMinHeightConstraint.isActive = isEditing + } + let completion: (Bool) -> Void = { [weak self] complete in + self?.titleTextField.text = self?.originalInputValue + } + + if animated { + UIView.animate(withDuration: 0.25, animations: changes, completion: completion) + } + else { + changes() + completion(true) + } + + if isEditing && becomeFirstResponder { + titleTextField.becomeFirstResponder() + } + else if !isEditing { + titleTextField.resignFirstResponder() + } + } // MARK: - Interaction public override func setHighlighted(_ highlighted: Bool, animated: Bool) { super.setHighlighted(highlighted, animated: animated) + // When editing disable the highlighted state changes (would result in UI elements + // reappearing otherwise) + guard !self.isEditingTitle else { return } + // If the 'cellSelectedBackgroundView' is hidden then there is no background so we // should update the titleLabel to indicate the highlighted state - if cellSelectedBackgroundView.isHidden { - titleLabel.alpha = (highlighted ? 0.8 : 1) + if cellSelectedBackgroundView.isHidden && shouldHighlightTitle { + // Note: We delay the "unhighlight" of the titleLabel so that the transition doesn't + // conflict with the transition into edit mode + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { [weak self] in + guard self?.isEditingTitle == false else { return } + + self?.titleLabel.alpha = (highlighted ? 0.8 : 1) + } } cellSelectedBackgroundView.alpha = (highlighted ? 1 : 0) @@ -440,12 +578,27 @@ public class SessionCell: UITableViewCell { public override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) - + leftAccessoryView.setSelected(selected, animated: animated) rightAccessoryView.setSelected(selected, animated: animated) } - - @objc private func extraActionTapped() { - onExtraActionTap?() +} + +// MARK: - Compose + +extension CombineCompatible where Self: SessionCell { + var textPublisher: AnyPublisher { + return self.titleTextField.publisher(for: [.editingChanged, .editingDidEnd]) + .handleEvents( + receiveOutput: { [weak self] textField in + // When editing the text update the 'accessibilityLabel' of the cell to match + // the text + let targetText: String? = (textField.isEditing ? textField.text : self?.titleLabel.text) + self?.accessibilityLabel = (targetText ?? self?.accessibilityLabel) + } + ) + .filter { $0.isEditing } // Don't bother sending events for 'editingDidEnd' + .map { textField -> String in (textField.text ?? "") } + .eraseToAnyPublisher() } } diff --git a/Session/Shared/Views/SessionHeaderView.swift b/Session/Shared/Views/SessionHeaderView.swift index bcb0fb768..4cb54f798 100644 --- a/Session/Shared/Views/SessionHeaderView.swift +++ b/Session/Shared/Views/SessionHeaderView.swift @@ -4,34 +4,44 @@ import UIKit import SessionUIKit class SessionHeaderView: UITableViewHeaderFooterView { - private lazy var emptyHeightConstraint: NSLayoutConstraint = self.heightAnchor - .constraint(equalToConstant: (Values.verySmallSpacing * 2)) - private lazy var filledHeightConstraint: NSLayoutConstraint = self.heightAnchor - .constraint(greaterThanOrEqualToConstant: Values.mediumSpacing) - // MARK: - UI - private let stackView: UIStackView = { - let result: UIStackView = UIStackView() - result.translatesAutoresizingMaskIntoConstraints = false - result.axis = .vertical - result.distribution = .fill - result.alignment = .fill - result.isLayoutMarginsRelativeArrangement = true - - return result - }() + private lazy var titleLabelConstraints: [NSLayoutConstraint] = [ + titleLabel.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing), + titleLabel.pin(.bottom, to: .bottom, of: self, withInset: -Values.mediumSpacing) + ] + private lazy var titleLabelLeadingConstraint: NSLayoutConstraint = titleLabel.pin(.leading, to: .leading, of: self) + private lazy var titleLabelTrailingConstraint: NSLayoutConstraint = titleLabel.pin(.trailing, to: .trailing, of: self) + private lazy var titleSeparatorLeadingConstraint: NSLayoutConstraint = titleSeparator.pin(.leading, to: .leading, of: self) + private lazy var titleSeparatorTrailingConstraint: NSLayoutConstraint = titleSeparator.pin(.trailing, to: .trailing, of: self) private let titleLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false result.font = .systemFont(ofSize: Values.mediumFontSize) result.themeTextColor = .textSecondary + result.isHidden = true return result }() - private let separator: UIView = UIView.separator() + private let titleSeparator: Separator = { + let result: Separator = Separator() + result.isHidden = true + + return result + }() + + private let loadingIndicator: UIActivityIndicatorView = { + let result: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium) + result.themeTintColor = .textPrimary + result.alpha = 0.5 + result.startAnimating() + result.hidesWhenStopped = true + result.isHidden = true + + return result + }() // MARK: - Initialization @@ -41,10 +51,9 @@ class SessionHeaderView: UITableViewHeaderFooterView { self.backgroundView = UIView() self.backgroundView?.themeBackgroundColor = .backgroundPrimary - addSubview(stackView) - addSubview(separator) - - stackView.addArrangedSubview(titleLabel) + addSubview(titleLabel) + addSubview(titleSeparator) + addSubview(loadingIndicator) setupLayout() } @@ -54,42 +63,59 @@ class SessionHeaderView: UITableViewHeaderFooterView { } private func setupLayout() { - stackView.pin(to: self) + titleLabel.pin(.top, to: .top, of: self, withInset: Values.mediumSpacing) + titleLabel.pin(.bottom, to: .bottom, of: self, withInset: Values.mediumSpacing) + titleLabel.center(.vertical, in: self) - separator.pin(.left, to: .left, of: self) - separator.pin(.right, to: .right, of: self) - separator.pin(.bottom, to: .bottom, of: self) + titleSeparator.center(.vertical, in: self) + loadingIndicator.center(in: self) } // MARK: - Content + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.isHidden = true + titleSeparator.isHidden = true + loadingIndicator.isHidden = true + + titleLabelLeadingConstraint.isActive = false + titleLabelTrailingConstraint.isActive = false + titleLabelConstraints.forEach { $0.isActive = false } + + titleSeparator.center(.vertical, in: self) + titleSeparatorLeadingConstraint.isActive = false + titleSeparatorTrailingConstraint.isActive = false + } + public func update( - style: SessionCell.Style = .rounded, title: String?, - hasSeparator: Bool + style: SessionTableSectionStyle = .titleRoundedContent ) { let titleIsEmpty: Bool = (title ?? "").isEmpty - let edgePadding: CGFloat = { - switch style { - case .rounded: - // Align to the start of the text in the cell - return (Values.largeSpacing + Values.mediumSpacing) - - case .edgeToEdge, .roundedEdgeToEdge: return Values.largeSpacing - } - }() - titleLabel.text = title - titleLabel.isHidden = titleIsEmpty - stackView.layoutMargins = UIEdgeInsets( - top: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing), - left: edgePadding, - bottom: (titleIsEmpty ? Values.verySmallSpacing : Values.mediumSpacing), - right: edgePadding - ) - emptyHeightConstraint.isActive = titleIsEmpty - filledHeightConstraint.isActive = !titleIsEmpty - separator.isHidden = (style == .rounded || !hasSeparator) + switch style { + case .titleRoundedContent, .titleEdgeToEdgeContent, .titleNoBackgroundContent: + titleLabel.text = title + titleLabel.isHidden = titleIsEmpty + titleLabelLeadingConstraint.constant = style.edgePadding + titleLabelTrailingConstraint.constant = -style.edgePadding + titleLabelLeadingConstraint.isActive = !titleIsEmpty + titleLabelTrailingConstraint.isActive = !titleIsEmpty + titleLabelConstraints.forEach { $0.isActive = true } + + case .titleSeparator: + titleSeparator.update(title: title) + titleSeparator.isHidden = false + titleSeparatorLeadingConstraint.constant = style.edgePadding + titleSeparatorTrailingConstraint.constant = -style.edgePadding + titleSeparatorLeadingConstraint.isActive = !titleIsEmpty + titleSeparatorTrailingConstraint.isActive = !titleIsEmpty + + case .none, .padding: break + case .loadMore: loadingIndicator.isHidden = false + } self.layoutIfNeeded() } diff --git a/Session/Utilities/BackgroundPoller.swift b/Session/Utilities/BackgroundPoller.swift index af1267fdd..df82c5ad7 100644 --- a/Session/Utilities/BackgroundPoller.swift +++ b/Session/Utilities/BackgroundPoller.swift @@ -1,68 +1,101 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SessionSnodeKit import SessionMessagingKit import SessionUtilitiesKit public final class BackgroundPoller { - private static var promises: [Promise] = [] + private static var publishers: [AnyPublisher] = [] public static var isValid: Bool = false - public static func poll(completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - promises = [] - .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( - calledFromBackgroundPoller: true, - isBackgroundPollerValid: { BackgroundPoller.isValid }, - isPostCapabilitiesRetry: false - ) - } + public static func poll( + completionHandler: @escaping (UIBackgroundFetchResult) -> Void, + dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies( + subscribeQueue: .global(qos: .background), + receiveQueue: .main + ) + ) { + Publishers + .MergeMany( + [pollForMessages(using: dependencies)] + .appending(contentsOf: pollForClosedGroupMessages(using: dependencies)) + .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 + /// + /// We also want to exclude any rooms which have failed to poll too many times in a row from + /// the background poll as they are likely to fail again + try OpenGroup + .select(.server) + .filter( + OpenGroup.Columns.roomToken != "" && + OpenGroup.Columns.isActive && + OpenGroup.Columns.pollFailureCount < OpenGroupAPI.Poller.maxRoomFailureCountForBackgroundPoll + ) + .distinct() + .asRequest(of: String.self) + .fetchSet(db) + } + .defaulting(to: []) + .map { server -> AnyPublisher in + let poller: OpenGroupAPI.Poller = OpenGroupAPI.Poller(for: server) + poller.stop() + + return poller.poll( + calledFromBackgroundPoller: true, + isBackgroundPollerValid: { BackgroundPoller.isValid }, + isPostCapabilitiesRetry: false, + using: dependencies + ) + } + ) + ) + .subscribe(on: dependencies.subscribeQueue) + .receive(on: dependencies.receiveQueue) + .collect() + .sinkUntilComplete( + receiveCompletion: { result in + // If we have already invalidated the timer then do nothing (we essentially timed out) + guard BackgroundPoller.isValid else { return } + + switch result { + case .finished: completionHandler(.newData) + case .failure(let error): + SNLog("Background poll failed due to error: \(error)") + completionHandler(.failed) + } + } ) - - when(resolved: promises) - .done { _ in - // If we have already invalidated the timer then do nothing (we essentially timed out) - guard BackgroundPoller.isValid else { return } - - completionHandler(.newData) - } - .catch { error in - // If we have already invalidated the timer then do nothing (we essentially timed out) - guard BackgroundPoller.isValid else { return } - - SNLog("Background poll failed due to error: \(error)") - completionHandler(.failed) - } } - private static func pollForMessages() -> Promise { + private static func pollForMessages( + using dependencies: OpenGroupManager.OGMDependencies + ) -> AnyPublisher { let userPublicKey: String = getUserHexEncodedPublicKey() - return getMessages(for: userPublicKey) + + return SnodeAPI.getSwarm(for: userPublicKey) + .tryFlatMapWithRandomSnode { snode -> AnyPublisher<[Message], Error> in + CurrentUserPoller.poll( + namespaces: CurrentUserPoller.namespaces, + from: snode, + for: userPublicKey, + calledFromBackgroundPoller: true, + isBackgroundPollValid: { BackgroundPoller.isValid }, + using: dependencies + ) + } + .map { _ in () } + .eraseToAnyPublisher() } - private static func pollForClosedGroupMessages() -> [Promise] { + private static func pollForClosedGroupMessages( + using dependencies: OpenGroupManager.OGMDependencies + ) -> [AnyPublisher] { // 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 @@ -78,109 +111,23 @@ public final class BackgroundPoller { } .defaulting(to: []) .map { groupPublicKey in - ClosedGroupPoller.poll( - groupPublicKey, - on: DispatchQueue.main, - maxRetryCount: 0, - calledFromBackgroundPoller: 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 SnodeAPIError.generic } - - return SnodeAPI.getMessages(from: snode, associatedWith: publicKey) - .then(on: DispatchQueue.main) { messages, lastHash -> Promise in - guard !messages.isEmpty, BackgroundPoller.isValid else { return Promise.value(()) } - - var jobsToRun: [Job] = [] - var messageCount: Int = 0 - var hadValidHashUpdate: Bool = false - - 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 MessageReceiverError.duplicateMessageNewSnode: - hadValidHashUpdate = true - 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 - messageCount += threadMessages.count - - let maybeJob: Job? = Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: threadId, - details: MessageReceiveJob.Details( - messages: threadMessages.map { $0.messageInfo }, - calledFromBackgroundPoller: 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) - } - - if messageCount == 0 && !hadValidHashUpdate, let lastHash: String = lastHash { - // Update the cached validity of the messages - try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( - db, - potentiallyInvalidHashes: [lastHash], - otherKnownValidHashes: messages.map { $0.info.hash } - ) - } + SnodeAPI.getSwarm(for: groupPublicKey) + .tryFlatMap { swarm -> AnyPublisher<[Message], Error> in + guard let snode: Snode = swarm.randomElement() else { + throw OnionRequestAPIError.insufficientSnodes } - 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 ClosedGroupPoller.poll( + namespaces: ClosedGroupPoller.namespaces, + from: snode, + for: groupPublicKey, + calledFromBackgroundPoller: true, + isBackgroundPollValid: { BackgroundPoller.isValid }, + using: dependencies + ) } + .map { _ in () } + .eraseToAnyPublisher() } } } diff --git a/Session/Utilities/CGRect+Utilities.swift b/Session/Utilities/CGRect+Utilities.swift index 68f8338da..8d2034a5c 100644 --- a/Session/Utilities/CGRect+Utilities.swift +++ b/Session/Utilities/CGRect+Utilities.swift @@ -1,6 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation extension CGRect { - init(center: CGPoint, size: CGSize) { let originX = center.x - size.width / 2 let originY = center.y - size.height / 2 diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index d4ce36559..b27698f5c 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -3,13 +3,11 @@ import GRDB import SessionSnodeKit final class IP2Country { - var countryNamesCache: Atomic<[String: String]> = Atomic([:]) - - - private static let workQueue = DispatchQueue(label: "IP2Country.workQueue", qos: .utility) // It's important that this is a serial queue static var isInitialized = false - // MARK: Tables + var countryNamesCache: Atomic<[String: String]> = Atomic([:]) + + // MARK: - Tables /// This table has two columns: the "network" column and the "registered_country_geoname_id" column. The network column contains /// the **lower** bound of an IP range and the "registered_country_geoname_id" column contains the ID of the country corresponding /// to that range. We look up an IP by finding the first index in the network column where the value is greater than the IP we're looking @@ -58,13 +56,12 @@ final class IP2Country { } @objc func populateCacheIfNeededAsync() { - // This has to be sync since the `countryNamesCache` dict doesn't like async access - IP2Country.workQueue.sync { [weak self] in - _ = self?.populateCacheIfNeeded() + DispatchQueue.global(qos: .utility).async { [weak self] in + self?.populateCacheIfNeeded() } } - func populateCacheIfNeeded() -> Bool { + @discardableResult func populateCacheIfNeeded() -> Bool { guard let pathToDisplay: [Snode] = OnionRequestAPI.paths.first else { return false } countryNamesCache.mutate { [weak self] cache in diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index 737856878..bff0eb9b3 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -10,14 +10,16 @@ public enum MentionUtilities { in string: String, threadVariant: SessionThread.Variant, currentUserPublicKey: String, - currentUserBlindedPublicKey: String? + currentUserBlinded15PublicKey: String?, + currentUserBlinded25PublicKey: String? ) -> String { /// **Note:** We are returning the string here so the 'textColor' and 'primaryColor' values are irrelevant return highlightMentions( in: string, threadVariant: threadVariant, currentUserPublicKey: currentUserPublicKey, - currentUserBlindedPublicKey: currentUserBlindedPublicKey, + currentUserBlinded15PublicKey: currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: currentUserBlinded25PublicKey, isOutgoingMessage: false, textColor: .black, theme: .classicDark, @@ -30,7 +32,8 @@ public enum MentionUtilities { in string: String, threadVariant: SessionThread.Variant, currentUserPublicKey: String?, - currentUserBlindedPublicKey: String?, + currentUserBlinded15PublicKey: String?, + currentUserBlinded25PublicKey: String?, isOutgoingMessage: Bool, textColor: UIColor, theme: Theme, @@ -48,7 +51,8 @@ public enum MentionUtilities { var mentions: [(range: NSRange, isCurrentUser: Bool)] = [] let currentUserPublicKeys: Set = [ currentUserPublicKey, - currentUserBlindedPublicKey + currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey ] .compactMap { $0 } .asSet() diff --git a/Session/Utilities/MockDataGenerator.swift b/Session/Utilities/MockDataGenerator.swift index 457a50b89..1cbf94fbe 100644 --- a/Session/Utilities/MockDataGenerator.swift +++ b/Session/Utilities/MockDataGenerator.swift @@ -2,72 +2,10 @@ import Foundation import GRDB -import Sodium import Curve25519Kit import SessionMessagingKit enum MockDataGenerator { - // Note: This was taken from TensorFlow's Random (https://github.com/apple/swift/blob/bc8f9e61d333b8f7a625f74d48ef0b554726e349/stdlib/public/TensorFlow/Random.swift) - // the complex approach is needed due to an issue with Swift's randomElement(using:) - // generation (see https://stackoverflow.com/a/64897775 for more info) - struct ARC4RandomNumberGenerator: RandomNumberGenerator { - var state: [UInt8] = Array(0...255) - var iPos: UInt8 = 0 - var jPos: UInt8 = 0 - - init(seed: T) { - self.init( - seed: (0..<(UInt64.bitWidth / UInt64.bitWidth)).map { index in - UInt8(truncatingIfNeeded: seed >> (UInt8.bitWidth * index)) - } - ) - } - - init(seed: [UInt8]) { - precondition(seed.count > 0, "Length of seed must be positive") - precondition(seed.count <= 256, "Length of seed must be at most 256") - - // Note: Have to use a for loop instead of a 'forEach' otherwise - // it doesn't work properly (not sure why...) - var j: UInt8 = 0 - for i: UInt8 in 0...255 { - j &+= S(i) &+ seed[Int(i) % seed.count] - swapAt(i, j) - } - } - - /// Produce the next random UInt64 from the stream, and advance the internal state - mutating func next() -> UInt64 { - // Note: Have to use a for loop instead of a 'forEach' otherwise - // it doesn't work properly (not sure why...) - var result: UInt64 = 0 - for _ in 0.. UInt8 { - return state[Int(index)] - } - - /// Helper to swap elements of the state - private mutating func swapAt(_ i: UInt8, _ j: UInt8) { - state.swapAt(Int(i), Int(j)) - } - - /// Generates the next byte in the keystream. - private mutating func nextByte() -> UInt8 { - iPos &+= 1 - jPos &+= S(iPos) - swapAt(iPos, jPos) - return S(S(iPos) &+ S(jPos)) - } - } - // MARK: - Generation static var printProgress: Bool = true @@ -109,7 +47,8 @@ enum MockDataGenerator { logProgress("", "Start") // First create the thread used to indicate that the mock data has been generated - _ = try? SessionThread.fetchOrCreate(db, id: "MockDatabaseThread", variant: .contact) + _ = try? SessionThread + .fetchOrCreate(db, id: "MockDatabaseThread", variant: .contact, shouldBeVisible: false) // MARK: - -- DM Thread @@ -125,7 +64,7 @@ enum MockDataGenerator { logProgress("DM Thread \(threadIndex)", "Start") - let data = Data((0..<16).map { _ in UInt8.random(in: (UInt8.min...UInt8.max), using: &dmThreadRandomGenerator) }) + let data: Data = Data(dmThreadRandomGenerator.nextBytes(count: 16)) let randomSessionId: String = try! Identity.generate(from: data).x25519KeyPair.hexEncodedPublicKey let isMessageRequest: Bool = Bool.random(using: &dmThreadRandomGenerator) let contactNameLength: Int = ((5..<20).randomElement(using: &dmThreadRandomGenerator) ?? 0) @@ -134,9 +73,12 @@ enum MockDataGenerator { // Generate the thread let thread: SessionThread = try! SessionThread - .fetchOrCreate(db, id: randomSessionId, variant: .contact) - .with(shouldBeVisible: true) - .saved(db) + .fetchOrCreate( + db, + id: randomSessionId, + variant: .contact, + shouldBeVisible: true + ) // Generate the contact let contact: Contact = try! Contact( @@ -155,7 +97,9 @@ enum MockDataGenerator { id: randomSessionId, name: (0.. UISwipeActionsConfiguration? { + return actions.map { UISwipeActionsConfiguration(actions: $0) } + } + + static func generateSwipeActions( + _ actions: [SwipeAction], + for side: UIContextualAction.Side, + indexPath: IndexPath, + tableView: UITableView, + threadViewModel: SessionThreadViewModel, + viewController: UIViewController? + ) -> [UIContextualAction]? { + guard !actions.isEmpty else { return nil } + + let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500) + + // Note: for some reason the `UISwipeActionsConfiguration` expects actions to be left-to-right + // for leading actions, but right-to-left for trailing actions... + let targetActions: [SwipeAction] = (side == .trailing ? actions.reversed() : actions) + let actionBackgroundColor: [ThemeValue] = [ + .conversationButton_swipeDestructive, + .conversationButton_swipeSecondary, + .conversationButton_swipeTertiary + ] + + return targetActions + .enumerated() + .map { index, action -> UIContextualAction in + // Even though we have to reverse the actions above, the indexes in the view hierarchy + // are in the expected order + let targetIndex: Int = (side == .trailing ? (targetActions.count - index) : index) + let themeBackgroundColor: ThemeValue = actionBackgroundColor[ + index % actionBackgroundColor.count + ] + + switch action { + // MARK: -- toggleReadStatus + + case .toggleReadStatus: + let isUnread: Bool = ( + threadViewModel.threadWasMarkedUnread == true || + (threadViewModel.threadUnreadCount ?? 0) > 0 + ) + + return UIContextualAction( + title: (isUnread ? + "MARK_AS_READ".localized() : + "MARK_AS_UNREAD".localized() + ), + icon: (isUnread ? + UIImage(systemName: "envelope.open") : + UIImage(systemName: "envelope.badge") + ), + themeTintColor: .white, + themeBackgroundColor: .conversationButton_swipeRead, // Always Custom + side: side, + actionIndex: targetIndex, + indexPath: indexPath, + tableView: tableView + ) { _, _, completionHandler in + // Delay the change to give the cell "unswipe" animation some time to complete + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { + switch isUnread { + case true: threadViewModel.markAsRead( + target: .threadAndInteractions( + interactionsBeforeInclusive: threadViewModel.interactionId + ) + ) + + case false: threadViewModel.markAsUnread() + } + } + completionHandler(true) + } + + // MARK: -- hide + + case .hide: + return UIContextualAction( + title: "TXT_HIDE_TITLE".localized(), + icon: UIImage(systemName: "eye.slash"), + themeTintColor: .white, + themeBackgroundColor: themeBackgroundColor, + side: side, + actionIndex: targetIndex, + indexPath: indexPath, + tableView: tableView + ) { _, _, completionHandler in + switch threadViewModel.threadId { + case SessionThreadViewModel.messageRequestsSectionId: + Storage.shared.write { db in db[.hasHiddenMessageRequests] = true } + completionHandler(true) + + default: + let confirmationModalExplanation: NSAttributedString = { + let message = String( + format: "hide_note_to_self_confirmation_alert_message".localized(), + threadViewModel.displayName + ) + + return NSAttributedString(string: message) + .adding( + attributes: [ + .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) + ], + range: (message as NSString).range(of: threadViewModel.displayName) + ) + }() + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "hide_note_to_self_confirmation_alert_title".localized(), + body: .attributedText(confirmationModalExplanation), + confirmTitle: "TXT_HIDE_TITLE".localized(), + confirmAccessibility: Accessibility( + identifier: "Hide" + ), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + Storage.shared.writeAsync { db in + try SessionThread.deleteOrLeave( + db, + threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, + groupLeaveType: .forced, + calledFromConfigHandling: false + ) + } + viewController?.dismiss(animated: true, completion: nil) + + completionHandler(true) + }, + afterClosed: { completionHandler(false) } + ) + ) + + viewController?.present(confirmationModal, animated: true, completion: nil) + } + } + + // MARK: -- pin + + case .pin: + return UIContextualAction( + title: (threadViewModel.threadPinnedPriority > 0 ? + "UNPIN_BUTTON_TEXT".localized() : + "PIN_BUTTON_TEXT".localized() + ), + icon: (threadViewModel.threadPinnedPriority > 0 ? + UIImage(systemName: "pin.slash") : + UIImage(systemName: "pin") + ), + themeTintColor: .white, + themeBackgroundColor: .conversationButton_swipeTertiary, // Always Tertiary + side: side, + actionIndex: targetIndex, + indexPath: indexPath, + tableView: tableView + ) { _, _, completionHandler in + (tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)? + .optimisticUpdate( + isPinned: !(threadViewModel.threadPinnedPriority > 0) + ) + completionHandler(true) + + // Delay the change to give the cell "unswipe" animation some time to complete + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { + Storage.shared.writeAsync { db in + try SessionThread + .filter(id: threadViewModel.threadId) + .updateAllAndConfig( + db, + SessionThread.Columns.pinnedPriority + .set(to: (threadViewModel.threadPinnedPriority == 0 ? 1 : 0)) + ) + } + } + } + + // MARK: -- mute + + case .mute: + return UIContextualAction( + title: (threadViewModel.threadMutedUntilTimestamp == nil ? + "mute_button_text".localized() : + "unmute_button_text".localized() + ), + icon: (threadViewModel.threadMutedUntilTimestamp == nil ? + UIImage(systemName: "speaker.slash") : + UIImage(systemName: "speaker") + ), + iconHeight: Values.mediumFontSize, + themeTintColor: .white, + themeBackgroundColor: themeBackgroundColor, + side: side, + actionIndex: targetIndex, + indexPath: indexPath, + tableView: tableView + ) { _, _, completionHandler in + (tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)? + .optimisticUpdate( + isMuted: !(threadViewModel.threadMutedUntilTimestamp != nil) + ) + completionHandler(true) + + // Delay the change to give the cell "unswipe" animation some time to complete + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { + Storage.shared.writeAsync { db in + let currentValue: TimeInterval? = try SessionThread + .filter(id: threadViewModel.threadId) + .select(.mutedUntilTimestamp) + .asRequest(of: TimeInterval.self) + .fetchOne(db) + + try SessionThread + .filter(id: threadViewModel.threadId) + .updateAll( + db, + SessionThread.Columns.mutedUntilTimestamp.set( + to: (currentValue == nil ? + Date.distantFuture.timeIntervalSince1970 : + nil + ) + ) + ) + } + } + } + + // MARK: -- block + + case .block: + return UIContextualAction( + title: (threadViewModel.threadIsBlocked == true ? + "BLOCK_LIST_UNBLOCK_BUTTON".localized() : + "BLOCK_LIST_BLOCK_BUTTON".localized() + ), + icon: UIImage(named: "table_ic_block"), + iconHeight: Values.mediumFontSize, + themeTintColor: .white, + themeBackgroundColor: themeBackgroundColor, + side: side, + actionIndex: targetIndex, + indexPath: indexPath, + tableView: tableView + ) { [weak viewController] _, _, completionHandler in + let threadIsBlocked: Bool = (threadViewModel.threadIsBlocked == true) + let threadIsMessageRequest: Bool = (threadViewModel.threadIsMessageRequest == true) + let contactChanges: [ConfigColumnAssignment] = [ + Contact.Columns.isBlocked.set(to: !threadIsBlocked), + + /// **Note:** We set `didApproveMe` 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 + (!threadIsMessageRequest ? nil : Contact.Columns.didApproveMe.set(to: true)), + (!threadIsMessageRequest ? nil : Contact.Columns.isApproved.set(to: false)) + ].compactMap { $0 } + + let performBlock: (UIViewController?) -> () = { viewController in + (tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)? + .optimisticUpdate( + isBlocked: !threadIsBlocked + ) + viewController?.dismiss(animated: true, completion: nil) + completionHandler(true) + + // Delay the change to give the cell "unswipe" animation some time to complete + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { + Storage.shared + .writePublisher { db in + // Create the contact if it doesn't exist + try Contact + .fetchOrCreate(db, id: threadViewModel.threadId) + .save(db) + try Contact + .filter(id: threadViewModel.threadId) + .updateAllAndConfig(db, contactChanges) + + // Blocked message requests should be deleted + if threadIsMessageRequest { + try SessionThread.deleteOrLeave( + db, + threadId: threadViewModel.threadId, + threadVariant: .contact, + groupLeaveType: .silent, + calledFromConfigHandling: false + ) + } + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() + } + } + + switch threadIsMessageRequest { + case false: performBlock(nil) + case true: + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(), + confirmTitle: "BLOCK_LIST_BLOCK_BUTTON".localized(), + confirmAccessibility: Accessibility( + identifier: "Block" + ), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + performBlock(viewController) + }, + afterClosed: { completionHandler(false) } + ) + ) + + viewController?.present(confirmationModal, animated: true, completion: nil) + } + } + + // MARK: -- leave + + case .leave: + return UIContextualAction( + title: "LEAVE_BUTTON_TITLE".localized(), + icon: UIImage(systemName: "rectangle.portrait.and.arrow.right"), + iconHeight: Values.mediumFontSize, + themeTintColor: .white, + themeBackgroundColor: themeBackgroundColor, + side: side, + actionIndex: targetIndex, + indexPath: indexPath, + tableView: tableView + ) { [weak viewController] _, _, completionHandler in + let confirmationModalTitle: String = { + switch threadViewModel.threadVariant { + case .legacyGroup, .group: + return "leave_group_confirmation_alert_title".localized() + + default: return "leave_community_confirmation_alert_title".localized() + } + }() + + let confirmationModalExplanation: NSAttributedString = { + if threadViewModel.currentUserIsClosedGroupAdmin == true { + return NSAttributedString(string: "admin_group_leave_warning".localized()) + } + + let mutableAttributedString = NSMutableAttributedString( + string: String( + format: "leave_community_confirmation_alert_message".localized(), + threadViewModel.displayName + ) + ) + mutableAttributedString.addAttribute( + .font, + value: UIFont.boldSystemFont(ofSize: Values.smallFontSize), + range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName) + ) + return mutableAttributedString + }() + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: confirmationModalTitle, + body: .attributedText(confirmationModalExplanation), + confirmTitle: "LEAVE_BUTTON_TITLE".localized(), + confirmAccessibility: Accessibility( + identifier: "Leave" + ), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + Storage.shared.writeAsync { db in + try SessionThread.deleteOrLeave( + db, + threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, + groupLeaveType: .standard, + calledFromConfigHandling: false + ) + } + viewController?.dismiss(animated: true, completion: nil) + + completionHandler(true) + }, + afterClosed: { completionHandler(false) } + ) + ) + + viewController?.present(confirmationModal, animated: true, completion: nil) + } + + // MARK: -- delete + + case .delete: + return UIContextualAction( + title: "TXT_DELETE_TITLE".localized(), + icon: UIImage(named: "icon_bin"), + iconHeight: Values.mediumFontSize, + themeTintColor: .white, + themeBackgroundColor: themeBackgroundColor, + side: side, + actionIndex: targetIndex, + indexPath: indexPath, + tableView: tableView + ) { [weak viewController] _, _, completionHandler in + let isMessageRequest: Bool = (threadViewModel.threadIsMessageRequest == true) + let confirmationModalTitle: String = { + switch (threadViewModel.threadVariant, isMessageRequest) { + case (_, true): return "TXT_DELETE_TITLE".localized() + case (.contact, _): + return "delete_conversation_confirmation_alert_title".localized() + + case (.legacyGroup, _), (.group, _): + return "delete_group_confirmation_alert_title".localized() + + case (.community, _): return "TXT_DELETE_TITLE".localized() + } + }() + let confirmationModalExplanation: NSAttributedString = { + guard !isMessageRequest else { + return NSAttributedString( + string: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized() + ) + } + guard threadViewModel.currentUserIsClosedGroupAdmin == false else { + return NSAttributedString( + string: "admin_group_leave_warning".localized() + ) + } + + let message = String( + format: { + switch threadViewModel.threadVariant { + case .contact: + return + "delete_conversation_confirmation_alert_message".localized() + + case .legacyGroup, .group: + return + "delete_group_confirmation_alert_message".localized() + + case .community: + return "leave_community_confirmation_alert_message".localized() + } + }(), + threadViewModel.displayName + ) + + return NSAttributedString(string: message) + .adding( + attributes: [ + .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) + ], + range: (message as NSString).range(of: threadViewModel.displayName) + ) + }() + + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: confirmationModalTitle, + body: .attributedText(confirmationModalExplanation), + confirmTitle: "TXT_DELETE_TITLE".localized(), + confirmAccessibility: Accessibility( + identifier: "Confirm delete" + ), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + Storage.shared.writeAsync { db in + try SessionThread.deleteOrLeave( + db, + threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, + groupLeaveType: (isMessageRequest ? .silent : .forced), + calledFromConfigHandling: false + ) + } + viewController?.dismiss(animated: true, completion: nil) + + completionHandler(true) + }, + afterClosed: { completionHandler(false) } + ) + ) + + viewController?.present(confirmationModal, animated: true, completion: nil) + } + } + } + } +} diff --git a/SessionMessagingKit/Calls/WebRTCSession+MessageHandling.swift b/SessionMessagingKit/Calls/WebRTCSession+MessageHandling.swift index b0786bf30..ddbe690b1 100644 --- a/SessionMessagingKit/Calls/WebRTCSession+MessageHandling.swift +++ b/SessionMessagingKit/Calls/WebRTCSession+MessageHandling.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import WebRTC import SessionUtilitiesKit @@ -21,7 +22,7 @@ extension WebRTCSession { else { guard sdp.type == .offer else { return } - self?.sendAnswer(to: sessionId).retainUntilComplete() + self?.sendAnswer(to: sessionId).sinkUntilComplete() } }) } diff --git a/SessionMessagingKit/Calls/WebRTCSession.swift b/SessionMessagingKit/Calls/WebRTCSession.swift index e9c789158..71e098ae2 100644 --- a/SessionMessagingKit/Calls/WebRTCSession.swift +++ b/SessionMessagingKit/Calls/WebRTCSession.swift @@ -1,8 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import WebRTC import SessionUtilitiesKit import SessionSnodeKit @@ -81,7 +81,7 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { // MARK: - Error - public enum Error : LocalizedError { + public enum WebRTCSessionError: LocalizedError { case noThread public var errorDescription: String? { @@ -125,130 +125,152 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { message: CallMessage, interactionId: Int64?, in thread: SessionThread - ) throws -> Promise { + ) throws -> AnyPublisher { SNLog("[Calls] Sending pre-offer message.") - return try MessageSender - .sendNonDurably( - db, - message: message, - interactionId: interactionId, - in: thread + return MessageSender + .sendImmediate( + preparedSendData: try MessageSender + .preparedSendData( + db, + message: message, + to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant), + namespace: try Message.Destination + .from(db, threadId: thread.id, threadVariant: thread.variant) + .defaultNamespace, + interactionId: interactionId + ) ) - .done2 { - SNLog("[Calls] Pre-offer message has been sent.") - } + .handleEvents(receiveOutput: { _ in SNLog("[Calls] Pre-offer message has been sent.") }) + .eraseToAnyPublisher() } public func sendOffer( - _ db: Database, - to sessionId: String, + to thread: SessionThread, isRestartingICEConnection: Bool = false - ) -> Promise { + ) -> AnyPublisher { SNLog("[Calls] Sending offer message.") - let (promise, seal) = Promise.pending() 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) - 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(SnodeAPI.currentOffsetTimestampMs()) - ), - interactionId: nil, - in: thread + return Deferred { + Future { [weak self] resolver in + self?.peerConnection?.offer(for: mediaConstraints) { sdp, error in + if let error = 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).") + resolver(Result.failure(error)) + return + } + } + + Storage.shared + .writePublisher { db in + try MessageSender + .preparedSendData( + db, + message: CallMessage( + uuid: uuid, + kind: .offer, + sdps: [ sdp.sdp ], + sentTimestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()) + ), + to: try Message.Destination + .from(db, threadId: thread.id, threadVariant: thread.variant), + namespace: try Message.Destination + .from(db, threadId: thread.id, threadVariant: thread.variant) + .defaultNamespace, + interactionId: nil + ) + } + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: resolver(Result.success(())) + case .failure(let error): resolver(Result.failure(error)) + } + } ) } - .done2 { - seal.fulfill(()) - } - .catch2 { error in - seal.reject(error) - } - .retainUntilComplete() + } } - - return promise + .eraseToAnyPublisher() } - public func sendAnswer(to sessionId: String) -> Promise { + public func sendAnswer(to sessionId: String) -> AnyPublisher { SNLog("[Calls] Sending answer message.") - let (promise, seal) = Promise.pending() 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 + return Storage.shared + .readPublisher { db -> SessionThread in + guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId) else { + throw WebRTCSessionError.noThread + } + + return thread } - - 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) + .flatMap { [weak self] thread in + Future { resolver in + self?.peerConnection?.answer(for: mediaConstraints) { [weak self] sdp, error in + if let error = error { + resolver(Result.failure(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 resolver(Result.failure(error)) + } + } + + Storage.shared + .writePublisher { db in + try MessageSender + .preparedSendData( + db, + message: CallMessage( + uuid: uuid, + kind: .answer, + sdps: [ sdp.sdp ] + ), + to: try Message.Destination + .from(db, threadId: thread.id, threadVariant: thread.variant), + namespace: try Message.Destination + .from(db, threadId: thread.id, threadVariant: thread.variant) + .defaultNamespace, + interactionId: nil + ) + } + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: resolver(Result.success(())) + case .failure(let error): resolver(Result.failure(error)) + } + } + ) } } - - try? MessageSender - .sendNonDurably( - db, - message: CallMessage( - uuid: uuid, - kind: .answer, - sdps: [ sdp.sdp ] - ), - interactionId: nil, - in: thread - ) - .done2 { - seal.fulfill(()) - } - .catch2 { error in - seal.reject(error) - } - .retainUntilComplete() } - } - - return promise + .eraseToAnyPublisher() } private func queueICECandidateForSending(_ candidate: RTCIceCandidate) { @@ -269,26 +291,36 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { // 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.") - - 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() - } + Storage.shared + .writePublisher { db in + guard let thread: SessionThread = try SessionThread.fetchOne(db, id: contactSessionId) else { + throw WebRTCSessionError.noThread + } + + SNLog("[Calls] Batch sending \(candidates.count) ICE candidates.") + + return try MessageSender + .preparedSendData( + db, + message: CallMessage( + uuid: uuid, + kind: .iceCandidates( + sdpMLineIndexes: candidates.map { UInt32($0.sdpMLineIndex) }, + sdpMids: candidates.map { $0.sdpMid! } + ), + sdps: candidates.map { $0.sdp } + ), + to: try Message.Destination + .from(db, threadId: thread.id, threadVariant: thread.variant), + namespace: try Message.Destination + .from(db, threadId: thread.id, threadVariant: thread.variant) + .defaultNamespace, + interactionId: nil + ) + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .sinkUntilComplete() } public func endCall(_ db: Database, with sessionId: String) throws { @@ -296,17 +328,25 @@ public final class WebRTCSession : NSObject, RTCPeerConnectionDelegate { SNLog("[Calls] Sending end call message.") - try MessageSender.sendNonDurably( - db, - message: CallMessage( - uuid: self.uuid, - kind: .endCall, - sdps: [] - ), - interactionId: nil, - in: thread - ) - .retainUntilComplete() + let preparedSendData: MessageSender.PreparedSendData = try MessageSender + .preparedSendData( + db, + message: CallMessage( + uuid: self.uuid, + kind: .endCall, + sdps: [] + ), + to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant), + namespace: try Message.Destination + .from(db, threadId: thread.id, threadVariant: thread.variant) + .defaultNamespace, + interactionId: nil + ) + + MessageSender + .sendImmediate(preparedSendData: preparedSendData) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() } public func dropConnection() { diff --git a/SessionMessagingKit/Common Networking/Header.swift b/SessionMessagingKit/Common Networking/Header.swift deleted file mode 100644 index 6c33e41a3..000000000 --- a/SessionMessagingKit/Common Networking/Header.swift +++ /dev/null @@ -1,22 +0,0 @@ -// 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/QueryParam.swift b/SessionMessagingKit/Common Networking/QueryParam.swift deleted file mode 100644 index d50ffbab5..000000000 --- a/SessionMessagingKit/Common Networking/QueryParam.swift +++ /dev/null @@ -1,15 +0,0 @@ -// 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 - case updateTypes = "t" // String indicating the types of updates that the client supports - - case reactors = "reactors" -} diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 938dae99c..0aa4f5d37 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -1,8 +1,9 @@ import Foundation +import GRDB import SessionUtilitiesKit -public enum SNMessagingKit { // Just to make the external API nice - public static func migrations() -> TargetMigrations { +public enum SNMessagingKit: MigratableTarget { // Just to make the external API nice + public static func migrations(_ db: Database) -> TargetMigrations { return TargetMigrations( identifier: .messagingKit, migrations: [ @@ -24,10 +25,20 @@ public enum SNMessagingKit { // Just to make the external API nice [ _008_EmojiReacts.self, _009_OpenGroupPermission.self, - _010_AddThreadIdToFTS.self, + _010_AddThreadIdToFTS.self + ], // Add job priorities + [ _011_AddPendingReadReceipts.self, - _012_AddFTSIfNeeded.self - ] + _012_AddFTSIfNeeded.self, + _013_SessionUtilChanges.self, + // Wait until the feature is turned on before doing the migration that generates + // the config dump data + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + (Features.useSharedUtilForUserConfig(db) ? + _014_GenerateInitialUserConfigDumps.self : + (nil as Migration.Type?) + ) + ].compactMap { $0 } ] ) } @@ -44,8 +55,10 @@ public enum SNMessagingKit { // Just to make the external API nice JobRunner.setExecutor(MessageReceiveJob.self, for: .messageReceive) JobRunner.setExecutor(NotifyPushServerJob.self, for: .notifyPushServer) JobRunner.setExecutor(SendReadReceiptsJob.self, for: .sendReadReceipts) - JobRunner.setExecutor(AttachmentDownloadJob.self, for: .attachmentDownload) JobRunner.setExecutor(AttachmentUploadJob.self, for: .attachmentUpload) JobRunner.setExecutor(GroupLeavingJob.self, for: .groupLeaving) + JobRunner.setExecutor(AttachmentDownloadJob.self, for: .attachmentDownload) + JobRunner.setExecutor(ConfigurationSyncJob.self, for: .configurationSync) + JobRunner.setExecutor(ConfigMessageReceiveJob.self, for: .configMessageReceive) } } diff --git a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift index 713499ae7..7967974ac 100644 --- a/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift +++ b/SessionMessagingKit/Database/LegacyDatabase/SMKLegacy.swift @@ -3,7 +3,6 @@ import Foundation import Sodium import YapDatabase -import SignalCoreKit import SessionUtilitiesKit public enum SMKLegacy { @@ -89,10 +88,23 @@ public enum SMKLegacy { @objc(SNContact) public class _Contact: NSObject, NSCoding { + @objc(SNLegacyProfileKey) + public class _LegacyProfileKey: NSObject, NSCoding { + let keyData: Data + + public required init?(coder: NSCoder) { + keyData = coder.decodeObject(forKey: "keyData") as! Data + } + + public func encode(with coder: NSCoder) { + fatalError("encode(with:) should never be called for legacy types") + } + } + public let sessionID: String public var profilePictureURL: String? public var profilePictureFileName: String? - public var profileEncryptionKey: OWSAES256Key? + public var profileEncryptionKey: _LegacyProfileKey? public var threadID: String? public var isTrusted = false public var isApproved = false @@ -112,7 +124,7 @@ public enum SMKLegacy { 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 profileEncryptionKey = coder.decodeObject(forKey: "profilePictureEncryptionKey") as! _LegacyProfileKey? { self.profileEncryptionKey = profileEncryptionKey } if let threadID = coder.decodeObject(forKey: "threadID") as! String? { self.threadID = threadID } let isBlockedFlag: Bool = coder.decodeBool(forKey: "isBlocked") @@ -167,12 +179,10 @@ public enum SMKLegacy { 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 @@ -484,14 +494,14 @@ public enum SMKLegacy { let members: [Data] = self.members, let admins: [Data] = self.admins else { - SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + SNLogNotTests("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") throw StorageError.migrationFailed } return .new( publicKey: publicKey, name: name, - encryptionKeyPair: Box.KeyPair( + encryptionKeyPair: KeyPair( publicKey: encryptionKeyPair.publicKey.bytes, secretKey: encryptionKeyPair.privateKey.bytes ), @@ -502,7 +512,7 @@ public enum SMKLegacy { case "encryptionKeyPair": guard let wrappers: [_KeyPairWrapper] = self.wrappers else { - SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + SNLogNotTests("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") throw StorageError.migrationFailed } @@ -513,7 +523,7 @@ public enum SMKLegacy { let publicKey: String = wrapper.publicKey, let encryptedKeyPair: Data = wrapper.encryptedKeyPair else { - SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + SNLogNotTests("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") throw StorageError.migrationFailed } @@ -526,7 +536,7 @@ public enum SMKLegacy { case "nameChange": guard let name: String = self.name else { - SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + SNLogNotTests("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") throw StorageError.migrationFailed } @@ -536,7 +546,7 @@ public enum SMKLegacy { case "membersAdded": guard let members: [Data] = self.members else { - SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + SNLogNotTests("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") throw StorageError.migrationFailed } @@ -544,7 +554,7 @@ public enum SMKLegacy { case "membersRemoved": guard let members: [Data] = self.members else { - SNLog("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") + SNLogNotTests("[Migration Error] Unable to decode Legacy ClosedGroupControlMessage") throw StorageError.migrationFailed } @@ -590,7 +600,7 @@ public enum SMKLegacy { case "screenshot": return .screenshot case "mediaSaved": guard let timestamp: UInt64 = self.timestamp else { - SNLog("[Migration Error] Unable to decode Legacy DataExtractionNotification") + SNLogNotTests("[Migration Error] Unable to decode Legacy DataExtractionNotification") throw StorageError.migrationFailed } @@ -1634,7 +1644,7 @@ public enum SMKLegacy { } 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") + SNLogNotTests("[Migration Warning] Ignoring pending messageSend job for V1 OpenGroup") return nil } else if let destString: String = _MessageSendJob.process(rawDestination, type: "openGroupV2") { diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index dfc99d450..8c869e44d 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import Curve25519Kit import SessionUtilitiesKit import SessionSnodeKit diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index f065d6795..8918c1c9b 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -4,7 +4,6 @@ import Foundation import AVKit import GRDB import YapDatabase -import Curve25519Kit import SessionUtilitiesKit import SessionSnodeKit @@ -21,7 +20,7 @@ enum _003_YDBToGRDBMigration: Migration { // 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))") + SNLogNotTests("[Migration Warning] No legacy database, skipping \(target.key(with: self))") return } @@ -76,7 +75,7 @@ enum _003_YDBToGRDBMigration: Migration { // 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") + SNLogNotTests("[Migration Error] Found unknown migration") shouldFailMigration = true return } @@ -87,7 +86,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: --Contacts - SNLog("[Migration Info] \(target.key(with: self)) - Processing Contacts") + SNLogNotTests("[Migration Info] \(target.key(with: self)) - Processing Contacts") transaction.enumerateRows(inCollection: SMKLegacy.contactCollection) { _, object, _, _ in guard let contact = object as? SMKLegacy._Contact else { return } @@ -106,7 +105,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: --Threads - SNLog("[Migration Info] \(target.key(with: self)) - Processing Threads") + SNLogNotTests("[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 } @@ -143,7 +142,7 @@ enum _003_YDBToGRDBMigration: Migration { 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") + SNLogNotTests("[Migration Error] Unable to decode Closed Group") shouldFailMigration = true return } @@ -173,7 +172,7 @@ enum _003_YDBToGRDBMigration: Migration { } 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") + SNLogNotTests("[Migration Error] Unable to find open group info") shouldFailMigration = true return } @@ -204,7 +203,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: --Interactions - SNLog("[Migration Info] \(target.key(with: self)) - Processing Interactions") + SNLogNotTests("[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 @@ -222,7 +221,7 @@ enum _003_YDBToGRDBMigration: Migration { transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.interactionCollection) { _, object, _ in guard let interaction: SMKLegacy._DBInteraction = object as? SMKLegacy._DBInteraction else { - SNLog("[Migration Error] Unable to process interaction") + SNLogNotTests("[Migration Error] Unable to process interaction") shouldFailMigration = true return } @@ -262,11 +261,11 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: --Attachments - SNLog("[Migration Info] \(target.key(with: self)) - Processing Attachments") + SNLogNotTests("[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") + SNLogNotTests("[Migration Error] Unable to process attachment") shouldFailMigration = true return } @@ -306,7 +305,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: --Jobs - SNLog("[Migration Info] \(target.key(with: self)) - Processing Jobs") + SNLogNotTests("[Migration Info] \(target.key(with: self)) - Processing Jobs") transaction.enumerateRows(inCollection: SMKLegacy.notifyPushServerJobCollection) { _, object, _, _ in guard let job = object as? SMKLegacy._NotifyPNServerJob else { return } @@ -336,7 +335,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: --Preferences - SNLog("[Migration Info] \(target.key(with: self)) - Processing Preferences") + SNLogNotTests("[Migration Info] \(target.key(with: self)) - Processing Preferences") transaction.enumerateKeysAndObjects(inCollection: SMKLegacy.preferencesCollection) { key, object, _ in legacyPreferences[key] = object @@ -403,7 +402,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Insert Contacts - SNLog("[Migration Info] \(target.key(with: self)) - Inserting Contacts") + SNLogNotTests("[Migration Info] \(target.key(with: self)) - Inserting Contacts") try autoreleasepool { // Values for contact progress @@ -418,10 +417,12 @@ enum _003_YDBToGRDBMigration: Migration { try Profile( id: legacyContact.sessionID, name: (legacyContact.name ?? legacyContact.sessionID), + lastNameUpdate: 0, nickname: legacyContact.nickname, profilePictureUrl: legacyContact.profilePictureURL, profilePictureFileName: legacyContact.profilePictureFileName, - profileEncryptionKey: legacyContact.profileEncryptionKey + profileEncryptionKey: legacyContact.profileEncryptionKey?.keyData, + lastProfilePictureUpdate: 0 ).migrationSafeInsert(db) /// **Note:** The blow "shouldForce" flags are here to allow us to avoid having to run legacy migrations they @@ -509,7 +510,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Insert Threads - SNLog("[Migration Info] \(target.key(with: self)) - Inserting Threads & Interactions") + SNLogNotTests("[Migration Info] \(target.key(with: self)) - Inserting Threads & Interactions") var legacyInteractionToIdMap: [String: Int64] = [:] var legacyInteractionIdentifierToIdMap: [String: Int64] = [:] @@ -557,7 +558,7 @@ enum _003_YDBToGRDBMigration: Migration { // 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") + SNLogNotTests("[Migration Error] Unable to migrate thread with no id mapping") throw StorageError.migrationFailed } @@ -566,7 +567,7 @@ enum _003_YDBToGRDBMigration: Migration { switch legacyThread { case let groupThread as SMKLegacy._GroupThread: - threadVariant = (groupThread.isOpenGroup ? .openGroup : .closedGroup) + threadVariant = (groupThread.isOpenGroup ? .community : .legacyGroup) onlyNotifyForMentions = groupThread.isOnlyNotifyingForMentions default: @@ -610,7 +611,7 @@ enum _003_YDBToGRDBMigration: Migration { let groupModel: SMKLegacy._GroupModel = closedGroupModel[legacyThread.uniqueId], let formationTimestamp: UInt64 = closedGroupFormation[legacyThread.uniqueId] else { - SNLog("[Migration Error] Closed group missing required data") + SNLogNotTests("[Migration Error] Closed group missing required data") throw StorageError.migrationFailed } @@ -635,14 +636,16 @@ enum _003_YDBToGRDBMigration: Migration { // Create the 'GroupMember' models for the group (even if the current user is no longer // a member as these objects are used to generate the group avatar icon) func createDummyProfile(profileId: String) { - SNLog("[Migration Warning] Closed group member with unknown user found - Creating empty profile") + SNLogNotTests("[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 + name: profileId, + lastNameUpdate: 0, + lastProfilePictureUpdate: 0 ).migrationSafeSave(db) } @@ -692,7 +695,7 @@ enum _003_YDBToGRDBMigration: Migration { let openGroup: SMKLegacy._OpenGroup = openGroupInfo[legacyThread.uniqueId], let targetOpenGroupServer: String = openGroupServer[legacyThread.uniqueId] else { - SNLog("[Migration Error] Open group missing required data") + SNLogNotTests("[Migration Error] Open group missing required data") throw StorageError.migrationFailed } @@ -877,7 +880,7 @@ enum _003_YDBToGRDBMigration: Migration { } default: - SNLog("[Migration Error] Unsupported interaction type") + SNLogNotTests("[Migration Error] Unsupported interaction type") throw StorageError.migrationFailed } @@ -940,11 +943,11 @@ enum _003_YDBToGRDBMigration: Migration { switch error { // Ignore duplicate interactions case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: - SNLog("[Migration Warning] Found duplicate message of variant: \(variant); skipping") + SNLogNotTests("[Migration Warning] Found duplicate message of variant: \(variant); skipping") return default: - SNLog("[Migration Error] Failed to insert interaction") + SNLogNotTests("[Migration Error] Failed to insert interaction") throw StorageError.migrationFailed } } @@ -961,7 +964,7 @@ enum _003_YDBToGRDBMigration: Migration { receivedMessageTimestamps.remove(legacyInteraction.timestamp) guard let interactionId: Int64 = interaction.id else { - SNLog("[Migration Error] Failed to insert interaction") + SNLogNotTests("[Migration Error] Failed to insert interaction") throw StorageError.migrationFailed } @@ -974,7 +977,10 @@ enum _003_YDBToGRDBMigration: Migration { .keys .map { $0 }) .defaulting(to: []), - destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil), + destination: (threadVariant == .contact ? + .contact(publicKey: threadId) : + nil + ), variant: variant, useFallback: false ) @@ -986,7 +992,10 @@ enum _003_YDBToGRDBMigration: Migration { .keys .map { $0 }) .defaulting(to: []), - destination: (threadVariant == .contact ? .contact(publicKey: threadId) : nil), + destination: (threadVariant == .contact ? + .contact(publicKey: threadId) : + nil + ), variant: variant, useFallback: true ) @@ -1041,14 +1050,16 @@ enum _003_YDBToGRDBMigration: Migration { // 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") + SNLogNotTests("[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 + name: quotedMessage.authorId, + lastNameUpdate: 0, + lastProfilePictureUpdate: 0 ).migrationSafeSave(db) } @@ -1072,7 +1083,7 @@ enum _003_YDBToGRDBMigration: Migration { .attachmentIds .first - SNLog([ + SNLogNotTests([ "[Migration Warning] Quote with invalid attachmentId found", (quoteAttachmentId == nil ? "Unable to reconcile, leaving attachment blank" : @@ -1151,7 +1162,7 @@ enum _003_YDBToGRDBMigration: Migration { ) guard let attachmentId: String = maybeAttachmentId else { - SNLog("[Migration Warning] Failed to create invalid attachment for missing attachment") + SNLogNotTests("[Migration Warning] Failed to create invalid attachment for missing attachment") return } @@ -1208,7 +1219,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Insert Jobs - SNLog("[Migration Info] \(target.key(with: self)) - Inserting Jobs") + SNLogNotTests("[Migration Info] \(target.key(with: self)) - Inserting Jobs") // MARK: --notifyPushServer @@ -1327,7 +1338,7 @@ enum _003_YDBToGRDBMigration: Migration { switch legacyJob.message { case is SMKLegacy._VisibleMessage: guard interactionId != nil else { - SNLog("[Migration Warning] Unable to find associated interaction to messageSend job, ignoring.") + SNLogNotTests("[Migration Warning] Unable to find associated interaction to messageSend job, ignoring.") return } @@ -1363,7 +1374,7 @@ enum _003_YDBToGRDBMigration: Migration { 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") + SNLogNotTests("[Migration Error] attachmentUpload job missing associated MessageSendJob") throw StorageError.migrationFailed } @@ -1381,7 +1392,7 @@ enum _003_YDBToGRDBMigration: Migration { // Add the dependency to the relevant MessageSendJob guard let uploadJobId: Int64 = uploadJob?.id else { - SNLog("[Migration Error] attachmentUpload job was not created") + SNLogNotTests("[Migration Error] attachmentUpload job was not created") throw StorageError.migrationFailed } @@ -1398,12 +1409,12 @@ enum _003_YDBToGRDBMigration: Migration { 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") + SNLogNotTests("[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") + SNLogNotTests("[Migration Warning] attachmentDownload job unable to find attachment - ignoring") return } @@ -1440,7 +1451,7 @@ enum _003_YDBToGRDBMigration: Migration { // MARK: - Preferences - SNLog("[Migration Info] \(target.key(with: self)) - Inserting Preferences") + SNLogNotTests("[Migration Info] \(target.key(with: self)) - Inserting Preferences") db[.defaultNotificationSound] = Preferences.Sound(rawValue: legacyPreferences[SMKLegacy.soundsGlobalNotificationKey] as? Int ?? -1) .defaulting(to: Preferences.Sound.defaultNotificationSound) @@ -1496,7 +1507,7 @@ enum _003_YDBToGRDBMigration: Migration { guard let legacyAttachmentId: String = legacyAttachmentId else { return nil } guard !processedAttachmentIds.contains(legacyAttachmentId) else { guard isQuotedMessage else { - SNLog("[Migration Error] Attempted to process duplicate attachment") + SNLogNotTests("[Migration Error] Attempted to process duplicate attachment") throw StorageError.migrationFailed } @@ -1504,7 +1515,7 @@ enum _003_YDBToGRDBMigration: Migration { } guard let legacyAttachment: SMKLegacy._Attachment = attachments[legacyAttachmentId] else { - SNLog("[Migration Warning] Missing attachment - interaction will show a \"failed\" attachment") + SNLogNotTests("[Migration Warning] Missing attachment - interaction will show a \"failed\" attachment") return nil } @@ -1856,6 +1867,10 @@ enum _003_YDBToGRDBMigration: Migration { SMKLegacy._MessageRequestResponse.self, forClassName: "SNMessageRequestResponse" ) + NSKeyedUnarchiver.setClass( + SMKLegacy._Contact._LegacyProfileKey.self, + forClassName: "OWSAES256Key" + ) } } diff --git a/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift b/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift index 97aa7462e..f8943a967 100644 --- a/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift +++ b/SessionMessagingKit/Database/Migrations/_004_RemoveLegacyYDB.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import Curve25519Kit import SessionUtilitiesKit import SessionSnodeKit diff --git a/SessionMessagingKit/Database/Migrations/_013_SessionUtilChanges.swift b/SessionMessagingKit/Database/Migrations/_013_SessionUtilChanges.swift new file mode 100644 index 000000000..f70169fca --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_013_SessionUtilChanges.swift @@ -0,0 +1,248 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import CryptoKit +import GRDB +import SessionUtil +import SessionUtilitiesKit + +/// This migration makes the neccessary changes to support the updated user config syncing system +enum _013_SessionUtilChanges: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "SessionUtilChanges" + static let needsConfigSync: Bool = true + static let minExpectedRunDuration: TimeInterval = 0.4 + + static func migrate(_ db: Database) throws { + // Add `markedAsUnread` to the thread table + try db.alter(table: SessionThread.self) { t in + t.add(.markedAsUnread, .boolean) + t.add(.pinnedPriority, .integer) + } + + // Add `lastNameUpdate` and `lastProfilePictureUpdate` columns to the profile table + try db.alter(table: Profile.self) { t in + t.add(.lastNameUpdate, .integer) + .notNull() + .defaults(to: 0) + t.add(.lastProfilePictureUpdate, .integer) + .notNull() + .defaults(to: 0) + } + + // SQLite doesn't support adding a new primary key after creation so we need to create a new table with + // the setup we want, copy data from the old table over, drop the old table and rename the new table + struct TmpGroupMember: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible { + static var databaseTableName: String { "tmpGroupMember" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case groupId + case profileId + case role + case isHidden + } + + public let groupId: String + public let profileId: String + public let role: GroupMember.Role + public let isHidden: Bool + } + + try db.create(table: TmpGroupMember.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() + .references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted + t.column(.profileId, .text) + .notNull() + t.column(.role, .integer).notNull() + t.column(.isHidden, .boolean) + .notNull() + .defaults(to: false) + + t.primaryKey([.groupId, .profileId, .role]) + } + + // Retrieve the non-duplicate group member entries from the old table + let nonDuplicateGroupMembers: [TmpGroupMember] = try GroupMember + .select(.groupId, .profileId, .role, .isHidden) + .group(GroupMember.Columns.groupId, GroupMember.Columns.profileId, GroupMember.Columns.role) + .asRequest(of: TmpGroupMember.self) + .fetchAll(db) + + // Insert into the new table, drop the old table and rename the new table to be the old one + try nonDuplicateGroupMembers.forEach { try $0.save(db) } + try db.drop(table: GroupMember.self) + try db.rename(table: TmpGroupMember.databaseTableName, to: GroupMember.databaseTableName) + + // Need to create the indexes separately from creating 'TmpGroupMember' to ensure they + // have the correct names + try db.createIndex(on: GroupMember.self, columns: [.groupId]) + try db.createIndex(on: GroupMember.self, columns: [.profileId]) + + // SQLite doesn't support removing unique constraints so we need to create a new table with + // the setup we want, copy data from the old table over, drop the old table and rename the new table + struct TmpClosedGroupKeyPair: Codable, TableRecord, FetchableRecord, PersistableRecord, ColumnExpressible { + static var databaseTableName: String { "tmpClosedGroupKeyPair" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId + case publicKey + case secretKey + case receivedTimestamp + case threadKeyPairHash + } + + public let threadId: String + public let publicKey: Data + public let secretKey: Data + public let receivedTimestamp: TimeInterval + public let threadKeyPairHash: String + } + + try db.alter(table: ClosedGroupKeyPair.self) { t in + t.add(.threadKeyPairHash, .text).defaults(to: "") + } + try db.create(table: TmpClosedGroupKeyPair.self) { t in + t.column(.threadId, .text) + .notNull() + .references(ClosedGroup.self, onDelete: .cascade) // Delete if ClosedGroup deleted + t.column(.publicKey, .blob).notNull() + t.column(.secretKey, .blob).notNull() + t.column(.receivedTimestamp, .double) + .notNull() + t.column(.threadKeyPairHash, .integer) + .notNull() + .unique() + } + + // Insert into the new table, drop the old table and rename the new table to be the old one + try ClosedGroupKeyPair + .fetchAll(db) + .map { keyPair in + ClosedGroupKeyPair( + threadId: keyPair.threadId, + publicKey: keyPair.publicKey, + secretKey: keyPair.secretKey, + receivedTimestamp: keyPair.receivedTimestamp + ) + } + .map { keyPair in + TmpClosedGroupKeyPair( + threadId: keyPair.threadId, + publicKey: keyPair.publicKey, + secretKey: keyPair.secretKey, + receivedTimestamp: keyPair.receivedTimestamp, + threadKeyPairHash: keyPair.threadKeyPairHash + ) + } + .forEach { try? $0.insert(db) } // Ignore duplicate values + try db.drop(table: ClosedGroupKeyPair.self) + try db.rename(table: TmpClosedGroupKeyPair.databaseTableName, to: ClosedGroupKeyPair.databaseTableName) + + // Add an index for the 'ClosedGroupKeyPair' so we can lookup existing keys more easily + // + // Note: Need to create the indexes separately from creating 'TmpClosedGroupKeyPair' to ensure they + // have the correct names + try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadId]) + try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.receivedTimestamp]) + try db.createIndex(on: ClosedGroupKeyPair.self, columns: [.threadKeyPairHash]) + try db.createIndex( + on: ClosedGroupKeyPair.self, + columns: [.threadId, .threadKeyPairHash] + ) + + // Add an index for the 'Quote' table to speed up queries + try db.createIndex( + on: Quote.self, + columns: [.timestampMs] + ) + + // New table for storing the latest config dump for each type + try db.create(table: ConfigDump.self) { t in + t.column(.variant, .text) + .notNull() + t.column(.publicKey, .text) + .notNull() + .indexed() + t.column(.data, .blob) + .notNull() + t.column(.timestampMs, .integer) + .notNull() + .defaults(to: 0) + + t.primaryKey([.variant, .publicKey]) + } + + // Migrate the 'isPinned' value to 'pinnedPriority' + try SessionThread + .filter(SessionThread.Columns.isPinned == true) + .updateAll( + db, + SessionThread.Columns.pinnedPriority.set(to: 1) + ) + + // If we don't have an ed25519 key then no need to create cached dump data + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + /// Remove any hidden threads to avoid syncing them (they are basically shadow threads created by starting a conversation + /// but not sending a message so can just be cleared out) + /// + /// **Note:** Our settings defer foreign key checks to the end of the migration, unfortunately the `PRAGMA foreign_keys` + /// setting is also a no-on during transactions so we can't enable it for the delete action, as a result we need to manually clean + /// up any data associated with the threads we want to delete, at the time of this migration the following tables should cascade + /// delete when a thread is deleted: + /// - DisappearingMessagesConfiguration + /// - ClosedGroup + /// - GroupMember + /// - Interaction + /// - ThreadTypingIndicator + /// - PendingReadReceipt + let threadIdsToDelete: [String] = try SessionThread + .filter( + SessionThread.Columns.shouldBeVisible == false && + SessionThread.Columns.id != userPublicKey + ) + .select(.id) + .asRequest(of: String.self) + .fetchAll(db) + try SessionThread + .deleteAll(db, ids: threadIdsToDelete) + try DisappearingMessagesConfiguration + .filter(threadIdsToDelete.contains(DisappearingMessagesConfiguration.Columns.threadId)) + .deleteAll(db) + try ClosedGroup + .filter(threadIdsToDelete.contains(ClosedGroup.Columns.threadId)) + .deleteAll(db) + try GroupMember + .filter(threadIdsToDelete.contains(GroupMember.Columns.groupId)) + .deleteAll(db) + try Interaction + .filter(threadIdsToDelete.contains(Interaction.Columns.threadId)) + .deleteAll(db) + try ThreadTypingIndicator + .filter(threadIdsToDelete.contains(ThreadTypingIndicator.Columns.threadId)) + .deleteAll(db) + try PendingReadReceipt + .filter(threadIdsToDelete.contains(PendingReadReceipt.Columns.threadId)) + .deleteAll(db) + + /// There was previously a bug which allowed users to fully delete the 'Note to Self' conversation but we don't want that, so + /// create it again if it doesn't exists + /// + /// **Note:** Since migrations are run when running tests creating a random SessionThread will result in unexpected thread + /// counts so don't do this when running tests (this logic is the same as in `MainAppContext.isRunningTests` + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { + if (try SessionThread.exists(db, id: userPublicKey)) == false { + try SessionThread + .fetchOrCreate(db, id: userPublicKey, variant: .contact, shouldBeVisible: false) + } + } + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift new file mode 100644 index 000000000..04b565056 --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift @@ -0,0 +1,231 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtil +import SessionUtilitiesKit + +/// This migration goes through the current state of the database and generates config dumps for the user config types +/// +/// **Note:** This migration won't be run until the `useSharedUtilForUserConfig` feature flag is enabled +enum _014_GenerateInitialUserConfigDumps: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "GenerateInitialUserConfigDumps" + static let needsConfigSync: Bool = true + static let minExpectedRunDuration: TimeInterval = 4.0 + + static func migrate(_ db: Database) throws { + // If we have no ed25519 key then there is no need to create cached dump data + guard let secretKey: [UInt8] = Identity.fetchUserEd25519KeyPair(db)?.secretKey else { + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + return + } + + // Create the initial config state + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let timestampMs: Int64 = Int64(Date().timeIntervalSince1970 * 1000) + + SessionUtil.loadState(db, userPublicKey: userPublicKey, ed25519SecretKey: secretKey) + + // Retrieve all threads (we are going to base the config dump data on the active + // threads rather than anything else in the database) + let allThreads: [String: SessionThread] = try SessionThread + .fetchAll(db) + .reduce(into: [:]) { result, next in result[next.id] = next } + + // MARK: - UserProfile Config Dump + + try SessionUtil + .config(for: .userProfile, publicKey: userPublicKey) + .mutate { conf in + try SessionUtil.update( + profile: Profile.fetchOrCreateCurrentUser(db), + in: conf + ) + + try SessionUtil.updateNoteToSelf( + priority: { + guard allThreads[userPublicKey]?.shouldBeVisible == true else { return SessionUtil.hiddenPriority } + + return Int32(allThreads[userPublicKey]?.pinnedPriority ?? 0) + }(), + in: conf + ) + + if config_needs_dump(conf) { + try SessionUtil + .createDump( + conf: conf, + for: .userProfile, + publicKey: userPublicKey, + timestampMs: timestampMs + )? + .save(db) + } + } + + // MARK: - Contact Config Dump + + try SessionUtil + .config(for: .contacts, publicKey: userPublicKey) + .mutate { conf in + // Exclude Note to Self, community, group and outgoing blinded message requests + let validContactIds: [String] = allThreads + .values + .filter { thread in + thread.variant == .contact && + thread.id != userPublicKey && + SessionId(from: thread.id)?.prefix == .standard + } + .map { $0.id } + let contactsData: [ContactInfo] = try Contact + .filter( + Contact.Columns.isBlocked == true || + validContactIds.contains(Contact.Columns.id) + ) + .including(optional: Contact.profile) + .asRequest(of: ContactInfo.self) + .fetchAll(db) + let threadIdsNeedingContacts: [String] = validContactIds + .filter { contactId in !contactsData.contains(where: { $0.contact.id == contactId }) } + + try SessionUtil.upsert( + contactData: contactsData + .appending( + contentsOf: threadIdsNeedingContacts + .map { contactId in + ContactInfo( + contact: Contact.fetchOrCreate(db, id: contactId), + profile: nil + ) + } + ) + .map { data in + SessionUtil.SyncedContactInfo( + id: data.contact.id, + contact: data.contact, + profile: data.profile, + priority: { + guard allThreads[data.contact.id]?.shouldBeVisible == true else { + return SessionUtil.hiddenPriority + } + + return Int32(allThreads[data.contact.id]?.pinnedPriority ?? 0) + }(), + created: allThreads[data.contact.id]?.creationDateTimestamp + ) + }, + in: conf + ) + + if config_needs_dump(conf) { + try SessionUtil + .createDump( + conf: conf, + for: .contacts, + publicKey: userPublicKey, + timestampMs: timestampMs + )? + .save(db) + } + } + + // MARK: - ConvoInfoVolatile Config Dump + + try SessionUtil + .config(for: .convoInfoVolatile, publicKey: userPublicKey) + .mutate { conf in + let volatileThreadInfo: [SessionUtil.VolatileThreadInfo] = SessionUtil.VolatileThreadInfo + .fetchAll(db, ids: Array(allThreads.keys)) + + try SessionUtil.upsert( + convoInfoVolatileChanges: volatileThreadInfo, + in: conf + ) + + if config_needs_dump(conf) { + try SessionUtil + .createDump( + conf: conf, + for: .convoInfoVolatile, + publicKey: userPublicKey, + timestampMs: timestampMs + )? + .save(db) + } + } + + // MARK: - UserGroups Config Dump + + try SessionUtil + .config(for: .userGroups, publicKey: userPublicKey) + .mutate { conf in + let legacyGroupData: [SessionUtil.LegacyGroupInfo] = try SessionUtil.LegacyGroupInfo.fetchAll(db) + let communityData: [SessionUtil.OpenGroupUrlInfo] = try SessionUtil.OpenGroupUrlInfo + .fetchAll(db, ids: Array(allThreads.keys)) + + try SessionUtil.upsert( + legacyGroups: legacyGroupData, + in: conf + ) + try SessionUtil.upsert( + communities: communityData + .map { urlInfo in + SessionUtil.CommunityInfo( + urlInfo: urlInfo, + priority: Int32(allThreads[urlInfo.threadId]?.pinnedPriority ?? 0) + ) + }, + in: conf + ) + + if config_needs_dump(conf) { + try SessionUtil + .createDump( + conf: conf, + for: .userGroups, + publicKey: userPublicKey, + timestampMs: timestampMs + )? + .save(db) + } + } + + // MARK: - Threads + + try SessionUtil.updatingThreads(db, Array(allThreads.values)) + + // MARK: - Syncing + + // Enqueue a config sync job to ensure the generated configs get synced + db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(userPublicKey)) { db in + ConfigurationSyncJob.enqueue(db, publicKey: userPublicKey) + } + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } + + struct ContactInfo: FetchableRecord, Decodable, ColumnExpressible { + typealias Columns = CodingKeys + enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case contact + case profile + } + + let contact: Contact + let profile: Profile? + } + + struct GroupInfo: FetchableRecord, Decodable, ColumnExpressible { + typealias Columns = CodingKeys + enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case closedGroup + case disappearingMessagesConfiguration + case groupMembers + } + + let closedGroup: ClosedGroup + let disappearingMessagesConfiguration: DisappearingMessagesConfiguration? + let groupMembers: [GroupMember] + } +} diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 3f661fc20..bb575a6fa 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -3,8 +3,8 @@ import Foundation import AVFAudio import AVFoundation +import Combine import GRDB -import PromiseKit import SignalCoreKit import SessionUtilitiesKit import SessionSnodeKit @@ -982,171 +982,208 @@ extension Attachment { // 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)? - ) { + public enum Destination { + case fileServer + case openGroup(OpenGroup) + + var shouldEncrypt: Bool { + switch self { + case .fileServer: return true + case .openGroup: return false + } + } + } + + public struct PreparedData { + public let attachments: [Attachment] + } + + public static func prepare(attachments: [SignalAttachment]) -> PreparedData { + return PreparedData( + attachments: attachments.compactMap { signalAttachment in + Attachment( + variant: (signalAttachment.isVoiceMessage ? + .voiceMessage : + .standard + ), + contentType: signalAttachment.mimeType, + dataSource: signalAttachment.dataSource, + sourceFilename: signalAttachment.sourceFilename, + caption: signalAttachment.captionText + ) + } + ) + } + + public static func process( + _ db: Database, + data: PreparedData?, + for interactionId: Int64? + ) throws { + guard + let data: PreparedData = data, + let interactionId: Int64 = interactionId + else { return } + + try data.attachments + .enumerated() + .forEach { index, attachment in + let interactionAttachment: InteractionAttachment = InteractionAttachment( + albumIndex: index, + interactionId: interactionId, + attachmentId: attachment.id + ) + + try attachment.insert(db) + try interactionAttachment.insert(db) + } + } + + internal func upload(to destination: Attachment.Destination) -> AnyPublisher { // 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 + return Just(Attachment.fileId(for: self.downloadUrl)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } // Get the attachment guard var data = try? readDataFromFile() else { SNLog("Couldn't read attachment from disk.") - failure?(AttachmentError.noAttachment) - return + return Fail(error: AttachmentError.noAttachment) + .eraseToAnyPublisher() } 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 Storage.shared + .writePublisher { db -> (OpenGroupAPI.PreparedSendData?, String?, Data?, Data?) in + // 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 || + !destination.shouldEncrypt || + encryptionKey == nil || + digest == nil + else { + // Save the final upload info + _ = try? Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) + + return (nil, Attachment.fileId(for: self.downloadUrl), nil, nil) + } + + var encryptionKey: NSData = NSData() + var digest: NSData = NSData() + + // Encrypt the attachment if needed + if destination.shouldEncrypt { + guard let ciphertext = Cryptography.encryptAttachmentData(data, shouldPad: true, outKey: &encryptionKey, outDigest: &digest) else { + SNLog("Couldn't encrypt attachment.") + throw AttachmentError.encryptionFailed } - return self.with(state: .uploaded) + data = ciphertext } + // Check the file size + SNLog("File size: \(data.count) bytes.") + if data.count > FileServerAPI.maxFileSize { throw HTTPError.maxFileSizeExceeded } + + // Update the attachment to the 'uploading' state _ = try? Attachment .filter(id: attachmentId) - .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploaded)) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.uploading)) - return self.with(state: .uploaded) - }() - - guard uploadedAttachment != nil else { - SNLog("Couldn't update attachmentUpload job.") - failure?(StorageError.failedToSave) - return - } + // We need database access for OpenGroup uploads so generate prepared data + let preparedSendData: OpenGroupAPI.PreparedSendData? = try { + switch destination { + case .openGroup(let openGroup): + return try OpenGroupAPI + .preparedUploadFile( + db, + bytes: data.bytes, + to: openGroup.roomToken, + on: openGroup.server + ) + + default: return nil + } + }() - 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 + return ( + preparedSendData, + nil, + (destination.shouldEncrypt ? encryptionKey as Data : nil), + (destination.shouldEncrypt ? digest as Data : nil) + ) } - - processedAttachment = processedAttachment.with( - encryptionKey: encryptionKey as Data, - digest: digest as Data - ) - data = ciphertext - } - - // Check the file size - SNLog("File size: \(data.count) bytes.") - if data.count > FileServerAPI.maxFileSize { - 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)) + .flatMap { preparedSendData, existingFileId, encryptionKey, digest -> AnyPublisher<(String?, Data?, Data?), Error> in + // No need to upload if the file was already uploaded + if let fileId: String = existingFileId { + return Just((fileId, encryptionKey, digest)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - return processedAttachment.with(state: .uploading) + switch destination { + case .openGroup: + return OpenGroupAPI.send(data: preparedSendData) + .map { _, response -> (String, Data?, Data?) in (response.id, encryptionKey, digest) } + .eraseToAnyPublisher() + + case .fileServer: + return FileServerAPI.upload(data) + .map { response -> (String, Data?, Data?) in (response.id, encryptionKey, digest) } + .eraseToAnyPublisher() + } } - - _ = 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 + .flatMap { fileId, encryptionKey, digest -> AnyPublisher 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 ?? - (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) - ), - downloadUrl: "\(FileServerAPI.server)/files/\(fileId)" - ) - .saved(db) - } - - guard uploadedAttachment != nil else { - SNLog("Couldn't update attachmentUpload job.") - failure?(StorageError.failedToSave) - return - } - - success?(fileId) + Storage.shared + .writePublisher { db in + try self + .with( + serverId: fileId, + state: .uploaded, + creationTimestamp: ( + self.creationTimestamp ?? + (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) + ), + downloadUrl: fileId.map { "\(FileServerAPI.server)/file/\($0)" }, + encryptionKey: encryptionKey, + digest: digest + ) + .saved(db) + } + .map { _ in fileId } + .eraseToAnyPublisher() } - .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)) + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: + Storage.shared.write { db in + try Attachment + .filter(id: attachmentId) + .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) + } + } } - - failure?(error) - } + ) + .eraseToAnyPublisher() } } diff --git a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift index d5e8704c6..3a3d07498 100644 --- a/SessionMessagingKit/Database/Models/BlindedIdLookup.swift +++ b/SessionMessagingKit/Database/Models/BlindedIdLookup.swift @@ -130,7 +130,7 @@ public extension BlindedIdLookup { if isCheckingForOutbox && !contact.isApproved { try Contact .filter(id: contact.id) - .updateAll(db, Contact.Columns.isApproved.set(to: true)) + .updateAllAndConfig(db, Contact.Columns.isApproved.set(to: true)) } break diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 48be3511a..43e922ed0 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -2,12 +2,13 @@ import Foundation import GRDB +import DifferenceKit 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) + public static let thread = belongsTo(SessionThread.self, using: threadForeignKey) internal static let keyPairs = hasMany( ClosedGroupKeyPair.self, using: ClosedGroupKeyPair.closedGroupForeignKey @@ -21,6 +22,12 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe case formationTimestamp } + /// The Group public key takes up 32 bytes + static let pubkeyByteLength: Int = 32 + + /// The Group secret key takes up 32 bytes + static let secretKeyByteLength: Int = 32 + public var id: String { threadId } // Identifiable public var publicKey: String { threadId } @@ -87,3 +94,111 @@ public extension ClosedGroup { .fetchOne(db) } } + +// MARK: - Convenience + +public extension ClosedGroup { + enum LeaveType { + case standard + case silent + case forced + } + + static func removeKeysAndUnsubscribe( + _ db: Database? = nil, + threadId: String, + removeGroupData: Bool, + calledFromConfigHandling: Bool + ) throws { + try removeKeysAndUnsubscribe( + db, + threadIds: [threadId], + removeGroupData: removeGroupData, + calledFromConfigHandling: calledFromConfigHandling + ) + } + + static func removeKeysAndUnsubscribe( + _ db: Database? = nil, + threadIds: [String], + removeGroupData: Bool, + calledFromConfigHandling: Bool + ) throws { + guard !threadIds.isEmpty else { return } + guard let db: Database = db else { + Storage.shared.write { db in + try ClosedGroup.removeKeysAndUnsubscribe( + db, + threadIds: threadIds, + removeGroupData: removeGroupData, + calledFromConfigHandling: calledFromConfigHandling + ) + } + return + } + + // Remove the group from the database and unsubscribe from PNs + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + threadIds.forEach { threadId in + ClosedGroupPoller.shared.stopPolling(for: threadId) + + PushNotificationAPI + .performOperation( + .unsubscribe, + for: threadId, + publicKey: userPublicKey + ) + .sinkUntilComplete() + } + + // Remove the keys for the group + try ClosedGroupKeyPair + .filter(threadIds.contains(ClosedGroupKeyPair.Columns.threadId)) + .deleteAll(db) + + struct ThreadIdVariant: Decodable, FetchableRecord { + let id: String + let variant: SessionThread.Variant + } + + let threadVariants: [ThreadIdVariant] = try SessionThread + .select(.id, .variant) + .filter(ids: threadIds) + .asRequest(of: ThreadIdVariant.self) + .fetchAll(db) + + // Remove the remaining group data if desired + if removeGroupData { + try SessionThread // Intentionally use `deleteAll` here as this gets triggered via `deleteOrLeave` + .filter(ids: threadIds) + .deleteAll(db) + + try ClosedGroup + .filter(ids: threadIds) + .deleteAll(db) + + try GroupMember + .filter(threadIds.contains(GroupMember.Columns.groupId)) + .deleteAll(db) + } + + // If we weren't called from config handling then we need to remove the group + // data from the config + if !calledFromConfigHandling { + try SessionUtil.remove( + db, + legacyGroupIds: threadVariants + .filter { $0.variant == .legacyGroup } + .map { $0.id } + ) + + try SessionUtil.remove( + db, + groupIds: threadVariants + .filter { $0.variant == .group } + .map { $0.id } + ) + } + } +} diff --git a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift index 509fa0c9e..8cb6d368b 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroupKeyPair.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import CryptoKit import GRDB import SessionUtilitiesKit @@ -18,12 +19,14 @@ public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, Persistab case publicKey case secretKey case receivedTimestamp + case threadKeyPairHash } public let threadId: String public let publicKey: Data public let secretKey: Data public let receivedTimestamp: TimeInterval + public let threadKeyPairHash: String // MARK: - Relationships @@ -43,6 +46,12 @@ public struct ClosedGroupKeyPair: Codable, Equatable, FetchableRecord, Persistab self.publicKey = publicKey self.secretKey = secretKey self.receivedTimestamp = receivedTimestamp + + // This value has a unique constraint and is used for key de-duping so the formula + // shouldn't be modified unless all existing keys have their values updated + self.threadKeyPairHash = Insecure.MD5 + .hash(data: threadId.bytes + publicKey.bytes + secretKey.bytes) + .hexString } } diff --git a/SessionMessagingKit/Database/Models/ConfigDump.swift b/SessionMessagingKit/Database/Models/ConfigDump.swift new file mode 100644 index 000000000..8f20f1466 --- /dev/null +++ b/SessionMessagingKit/Database/Models/ConfigDump.swift @@ -0,0 +1,90 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionSnodeKit +import SessionUtilitiesKit + +public struct ConfigDump: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "configDump" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case variant + case publicKey + case data + case timestampMs + } + + public enum Variant: String, Codable, DatabaseValueConvertible { + case userProfile + case contacts + case convoInfoVolatile + case userGroups + } + + /// The type of config this dump is for + public let variant: Variant + + /// The public key for the swarm this dump is for + /// + /// **Note:** For user config items this will be an empty string + public let publicKey: String + + /// The data for this dump + public let data: Data + + /// When the configDump was created in milliseconds since epoch + public let timestampMs: Int64 + + internal init( + variant: Variant, + publicKey: String, + data: Data, + timestampMs: Int64 + ) { + self.variant = variant + self.publicKey = publicKey + self.data = data + self.timestampMs = timestampMs + } +} + +// MARK: - Convenience + +public extension ConfigDump.Variant { + static let userVariants: [ConfigDump.Variant] = [ + .userProfile, .contacts, .convoInfoVolatile, .userGroups + ] + + var configMessageKind: SharedConfigMessage.Kind { + switch self { + case .userProfile: return .userProfile + case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .userGroups: return .userGroups + } + } + + var namespace: SnodeAPI.Namespace { + switch self { + case .userProfile: return SnodeAPI.Namespace.configUserProfile + case .contacts: return SnodeAPI.Namespace.configContacts + case .convoInfoVolatile: return SnodeAPI.Namespace.configConvoInfoVolatile + case .userGroups: return SnodeAPI.Namespace.configUserGroups + } + } + + /// This value defines the order that the SharedConfigMessages should be processed in, while we re-process config + /// messages every time we poll this will prevent an edge-case where data/logic between different config messages + /// could be dependant on each other (eg. there could be `convoInfoVolatile` data related to a new conversation + /// which hasn't been created yet because it's associated `contacts`/`userGroups` message hasn't yet been + /// processed (without this we would have to wait until the next poll for it to be processed correctly) + var processingOrder: Int { + switch self { + case .userProfile, .contacts: return 0 + case .userGroups: return 1 + case .convoInfoVolatile: return 2 + } + } +} diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index ab85bb808..6f3b05c47 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -4,6 +4,8 @@ import Foundation import GRDB import SessionUtilitiesKit +/// This type is duplicate in both the database and within the SessionUtil config so should only ever have it's data changes via the +/// `updateAllAndConfig` function. Updating it elsewhere could result in issues with syncing data between devices 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]) @@ -52,12 +54,13 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis isApproved: Bool = false, isBlocked: Bool = false, didApproveMe: Bool = false, - hasBeenBlocked: Bool = false + hasBeenBlocked: Bool = false, + dependencies: Dependencies = Dependencies() ) { self.id = id self.isTrusted = ( isTrusted || - id == getUserHexEncodedPublicKey() // Always trust ourselves + id == getUserHexEncodedPublicKey(dependencies: dependencies) // Always trust ourselves ) self.isApproved = isApproved self.isBlocked = isBlocked @@ -66,29 +69,6 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis } } -// 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 { @@ -100,22 +80,3 @@ public extension 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 index 709e97f0e..e23f54879 100644 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift @@ -41,6 +41,15 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable case unsendRequest = 7 case messageRequestResponse = 8 case call = 9 + + /// Since we retrieve messages from all snodes in a swarm there is a fun issue where a user can delete a + /// one-to-one conversation (which removes all associated interactions) and then the poller checks a + /// different service node, if a previously processed message hadn't been processed yet for that specific + /// service node it results in the conversation re-appearing + /// + /// This `Variant` allows us to create a record which survives thread deletion to prevent a duplicate + /// message from being reprocessed + case visibleMessageDedupe = 10 } /// The id for the thread the control message is associated to @@ -68,10 +77,6 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable 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 @@ -83,6 +88,12 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable // message handling to make sure the messages are for the same ongoing call if message is CallMessage { return nil } + // We don't want to do any de-duping for SharedConfigMessages as libSession will handle + // the deduping for us, it also gives libSession more options to potentially recover from + // invalid data, conflicts or even process new changes which weren't supported from older + // versions of the library as it will always re-process messages + if message is SharedConfigMessage { 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 @@ -103,10 +114,11 @@ public struct ControlMessageProcessRecord: Codable, FetchableRecord, Persistable case is ClosedGroupControlMessage: return .closedGroupControlMessage case is DataExtractionNotification: return .dataExtractionNotification case is ExpirationTimerUpdate: return .expirationTimerUpdate - case is ConfigurationMessage: return .configurationMessage + case is ConfigurationMessage, is SharedConfigMessage: return .configurationMessage case is UnsendRequest: return .unsendRequest case is MessageRequestResponse: return .messageRequestResponse case is CallMessage: return .call + case is VisibleMessage: return .visibleMessageDedupe default: preconditionFailure("[ControlMessageProcessRecord] Unsupported message type") } }() diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 963e2c65e..a3bbb8339 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -5,7 +5,7 @@ import GRDB import SessionUtilitiesKit import SessionSnodeKit -public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatable, Hashable, 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) @@ -17,11 +17,17 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Equatabl case durationSeconds } + public enum DisappearingMessageType: Int, Codable, Hashable, DatabaseValueConvertible { + case disappearAfterRead + case disappearAfterSend + } + public var id: String { threadId } // Identifiable public let threadId: String public let isEnabled: Bool public let durationSeconds: TimeInterval + public var type: DisappearingMessageType? { return nil } // TODO: Add as part of Disappearing Message Rebuild // MARK: - Relationships @@ -45,7 +51,8 @@ public extension DisappearingMessagesConfiguration { func with( isEnabled: Bool? = nil, - durationSeconds: TimeInterval? = nil + durationSeconds: TimeInterval? = nil, + type: DisappearingMessageType? = nil ) -> DisappearingMessagesConfiguration { return DisappearingMessagesConfiguration( threadId: threadId, @@ -126,100 +133,3 @@ extension DisappearingMessagesConfiguration { 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 floor(durationSeconds).formatted(format: .long) - } - - @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: SnodeAPI.currentOffsetTimestampMs() - ) - .inserted(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 index 4cfe0abd4..75fb605f0 100644 --- a/SessionMessagingKit/Database/Models/GroupMember.swift +++ b/SessionMessagingKit/Database/Models/GroupMember.swift @@ -4,7 +4,7 @@ import Foundation import GRDB import SessionUtilitiesKit -public struct GroupMember: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct GroupMember: Codable, Equatable, Hashable, 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]) @@ -60,39 +60,3 @@ public struct GroupMember: Codable, Equatable, FetchableRecord, PersistableRecor self.isHidden = isHidden } } - -// 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 index b163bc01a..21b29295b 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -319,7 +319,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu openGroupServerMessageId: Int64? = nil, openGroupWhisperMods: Bool = false, openGroupWhisperTo: String? = nil - ) throws { + ) { self.serverHash = serverHash self.messageUuid = messageUuid self.threadId = threadId @@ -379,7 +379,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu state: .sending ).insert(db) - case .closedGroup: + case .legacyGroup, .group: let closedGroupMemberIds: Set = (try? GroupMember .select(.profileId) .filter(GroupMember.Columns.groupId == threadId) @@ -405,7 +405,7 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu ).insert(db) } - case .openGroup: + case .community: // 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 @@ -489,7 +489,21 @@ public extension Interaction { } // Once all of the below is done schedule the jobs - func scheduleJobs(interactionInfo: [InteractionReadInfo]) { + func scheduleJobs( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + interactionInfo: [InteractionReadInfo], + lastReadTimestampMs: Int64 + ) throws { + // Update the last read timestamp if needed + try SessionUtil.syncThreadLastReadIfNeeded( + db, + threadId: threadId, + threadVariant: threadVariant, + lastReadTimestampMs: lastReadTimestampMs + ) + // Add the 'DisappearingMessagesJob' if needed - this will update any expiring // messages `expiresStartedAtMs` values JobRunner.upsert( @@ -548,6 +562,7 @@ public extension Interaction { // actually not read (no point updating and triggering db changes otherwise) guard maybeInteractionInfo?.wasRead == false, + let timestampMs: Int64 = maybeInteractionInfo?.timestampMs, let variant: Variant = try Interaction .filter(id: interactionId) .select(.variant) @@ -559,14 +574,20 @@ public extension Interaction { .filter(id: interactionId) .updateAll(db, Columns.wasRead.set(to: true)) - scheduleJobs(interactionInfo: [ - InteractionReadInfo( - id: interactionId, - variant: variant, - timestampMs: 0, - wasRead: false - ) - ]) + try scheduleJobs( + db, + threadId: threadId, + threadVariant: threadVariant, + interactionInfo: [ + InteractionReadInfo( + id: interactionId, + variant: variant, + timestampMs: 0, + wasRead: false + ) + ], + lastReadTimestampMs: timestampMs + ) return } @@ -583,7 +604,13 @@ public extension Interaction { // for this interaction (need to ensure the disapeparing messages run for sync'ed // outgoing messages which will always have 'wasRead' as false) guard !interactionInfoToMarkAsRead.isEmpty else { - scheduleJobs(interactionInfo: [interactionInfo]) + try scheduleJobs( + db, + threadId: threadId, + threadVariant: threadVariant, + interactionInfo: [interactionInfo], + lastReadTimestampMs: interactionInfo.timestampMs + ) return } @@ -591,7 +618,13 @@ public extension Interaction { try interactionQuery.updateAll(db, Columns.wasRead.set(to: true)) // Retrieve the interaction ids we want to update - scheduleJobs(interactionInfo: interactionInfoToMarkAsRead) + try scheduleJobs( + db, + threadId: threadId, + threadVariant: threadVariant, + interactionInfo: interactionInfoToMarkAsRead, + lastReadTimestampMs: interactionInfo.timestampMs + ) } /// This method flags sent messages as read for the specified recipients @@ -662,13 +695,28 @@ public extension Interaction { // MARK: - Search Queries public extension Interaction { - static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest { + struct TimestampInfo: FetchableRecord, Codable { + public let id: Int64 + public let timestampMs: Int64 + + public init( + id: Int64, + timestampMs: Int64 + ) { + self.id = id + self.timestampMs = timestampMs + } + } + + static func idsForTermWithin(threadId: String, pattern: FTS5Pattern) -> SQLRequest { let interaction: TypedTableAlias = TypedTableAlias() let interactionFullTextSearch: SQL = SQL(stringLiteral: Interaction.fullTextSearchTableName) let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name) - let request: SQLRequest = """ - SELECT \(interaction[.id]) + let request: SQLRequest = """ + SELECT + \(interaction[.id]), + \(interaction[.timestampMs]) FROM \(Interaction.self) JOIN \(interactionFullTextSearch) ON ( \(interactionFullTextSearch).rowid = \(interaction.alias[Column.rowID]) AND @@ -760,19 +808,30 @@ public extension Interaction { let sodium: Sodium = Sodium() if - let userEd25519KeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), - let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair( + let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), + let blindedKeyPair: KeyPair = sodium.blindedKeyPair( serverPublicKey: openGroup.publicKey, edKeyPair: userEd25519KeyPair, genericHash: sodium.genericHash ) { - publicKeysToCheck.append( - SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString - ) + publicKeysToCheck.append(SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString) + publicKeysToCheck.append(SessionId(.blinded25, publicKey: blindedKeyPair.publicKey).hexString) } } + return isUserMentioned( + publicKeysToCheck: publicKeysToCheck, + body: body, + quoteAuthorId: quoteAuthorId + ) + } + + static func isUserMentioned( + publicKeysToCheck: [String], + body: String?, + quoteAuthorId: String? = nil + ) -> Bool { // 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 @@ -798,10 +857,9 @@ public extension Interaction { .asRequest(of: Attachment.DescriptionInfo.self) .fetchOne(db), attachmentCount: try? attachments.fetchCount(db), - isOpenGroupInvitation: (try? linkPreview + isOpenGroupInvitation: linkPreview .filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation) - .isNotEmpty(db)) - .defaulting(to: false) + .isNotEmpty(db) ) case .infoMediaSavedNotification, .infoScreenshotNotification, .infoCall: diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 46b99ff74..b214bc78d 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -1,9 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit -import AFNetworking import SignalCoreKit import SessionUtilitiesKit import SessionSnodeKit @@ -131,7 +130,7 @@ public extension LinkPreview { return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution) } - static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? { + static func generateAttachmentIfPossible(imageData: Data?, mimeType: String) throws -> Attachment? { guard let imageData: Data = imageData, !imageData.isEmpty else { return nil } guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil } @@ -142,9 +141,7 @@ public extension LinkPreview { return nil } - return try Attachment(contentType: mimeType, dataSource: dataSource)? - .inserted(db) - .id + return Attachment(contentType: mimeType, dataSource: dataSource) } static func isValidLinkUrl(_ urlString: String) -> Bool { @@ -299,32 +296,40 @@ public extension LinkPreview { } } - static func tryToBuildPreviewInfo(previewUrl: String?) -> Promise { + static func tryToBuildPreviewInfo(previewUrl: String?) -> AnyPublisher { guard Storage.shared[.areLinkPreviewsEnabled] else { - return Promise(error: LinkPreviewError.featureDisabled) + return Fail(error: LinkPreviewError.featureDisabled) + .eraseToAnyPublisher() } guard let previewUrl: String = previewUrl else { - return Promise(error: LinkPreviewError.invalidInput) + return Fail(error: LinkPreviewError.invalidInput) + .eraseToAnyPublisher() } if let cachedInfo = cachedLinkPreview(forPreviewUrl: previewUrl) { - return Promise.value(cachedInfo) + return Just(cachedInfo) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } return downloadLink(url: previewUrl) - .then(on: DispatchQueue.global()) { data, response -> Promise in - return parseLinkDataAndBuildDraft(linkData: data, response: response, linkUrlString: previewUrl) + .flatMap { data, response in + parseLinkDataAndBuildDraft(linkData: data, response: response, linkUrlString: previewUrl) } - .then(on: DispatchQueue.global()) { linkPreviewDraft -> Promise in + .tryMap { linkPreviewDraft -> LinkPreviewDraft in guard linkPreviewDraft.isValid() else { throw LinkPreviewError.noPreview } setCachedLinkPreview(linkPreviewDraft, forPreviewUrl: previewUrl) - return Promise.value(linkPreviewDraft) + return linkPreviewDraft } + .eraseToAnyPublisher() } - private static func downloadLink(url urlString: String, remainingRetries: UInt = 3) -> Promise<(Data, URLResponse)> { + private static func downloadLink( + url urlString: String, + remainingRetries: UInt = 3 + ) -> AnyPublisher<(Data, URLResponse), Error> { Logger.verbose("url: \(urlString)") // let sessionConfiguration = ContentProxy.sessionConfiguration() // Loki: Signal's proxy appears to have been banned by YouTube @@ -334,108 +339,100 @@ public extension LinkPreview { 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) + guard + var request: URLRequest = URL(string: urlString).map({ URLRequest(url: $0) }), + ContentProxy.configureProxiedRequest(request: &request) + else { + return Fail(error: LinkPreviewError.assertionFailure) + .eraseToAnyPublisher() } - sessionManager.requestSerializer.setValue(self.userAgentString, forHTTPHeaderField: "User-Agent") + request.setValue(self.userAgentString, forHTTPHeaderField: "User-Agent") // Set a fake value - 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 + let session: URLSession = URLSession(configuration: sessionConfiguration) + + return session + .dataTaskPublisher(for: request) + .mapError { _ -> Error in HTTPError.generic } // URLError codes are negative values + .tryMap { data, response -> (Data, URLResponse) in + guard let urlResponse: HTTPURLResponse = response as? HTTPURLResponse else { + throw LinkPreviewError.assertionFailure } - if let contentType = response.allHeaderFields["Content-Type"] as? String { + if let contentType: String = urlResponse.allHeaderFields["Content-Type"] as? String { guard contentType.lowercased().hasPrefix("text/") else { - resolver.reject(LinkPreviewError.invalidContent) - return + throw LinkPreviewError.invalidContent } } - guard let data = value as? Data else { - resolver.reject(LinkPreviewError.assertionFailure) - return - } - guard data.count > 0 else { - resolver.reject(LinkPreviewError.invalidContent) - return - } + guard data.count > 0 else { throw LinkPreviewError.invalidContent } - 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 (data, response) } - ) - - return promise + .catch { error -> AnyPublisher<(Data, URLResponse), Error> in + guard isRetryable(error: error), remainingRetries > 0 else { + return Fail(error: LinkPreviewError.couldNotDownload) + .eraseToAnyPublisher() + } + + return LinkPreview + .downloadLink( + url: urlString, + remainingRetries: (remainingRetries - 1) + ) + } + .eraseToAnyPublisher() } - private static func parseLinkDataAndBuildDraft(linkData: Data, response: URLResponse, linkUrlString: String) -> Promise { + private static func parseLinkDataAndBuildDraft( + linkData: Data, + response: URLResponse, + linkUrlString: String + ) -> AnyPublisher { 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)) + return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } guard URL(string: imageUrl) != nil else { - return Promise.value(LinkPreviewDraft(urlString: linkUrlString, title: title)) + return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } guard let imageFileExtension = fileExtension(forImageUrl: imageUrl) else { - return Promise.value(LinkPreviewDraft(urlString: linkUrlString, title: title)) + return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } guard let imageMimeType = mimetype(forImageFileExtension: imageFileExtension) else { - return Promise.value(LinkPreviewDraft(urlString: linkUrlString, title: title)) + return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - return downloadImage(url: imageUrl, imageMimeType: imageMimeType) - .map(on: DispatchQueue.global()) { (imageData: Data) -> LinkPreviewDraft in + return LinkPreview + .downloadImage(url: imageUrl, imageMimeType: imageMimeType) + .map { imageData -> 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 { _ -> AnyPublisher in + return Just(LinkPreviewDraft(urlString: linkUrlString, title: title)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } + .eraseToAnyPublisher() } catch { - return Promise(error: error) + return Fail(error: error) + .eraseToAnyPublisher() } } private static func parse(linkData: Data, response: URLResponse) throws -> Contents { - guard let linkText = String(data: linkData, urlResponse: response) else { + guard let linkText = String(bytes: linkData, encoding: response.stringEncoding ?? .utf8) else { print("Could not parse link text.") throw LinkPreviewError.invalidInput } @@ -464,68 +461,67 @@ public extension LinkPreview { 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) + private static func downloadImage( + url urlString: String, + imageMimeType: String + ) -> AnyPublisher { + guard + let url = URL(string: urlString), + let assetDescription: ProxiedContentAssetDescription = ProxiedContentAssetDescription( + url: url as NSURL + ) + else { + return Fail(error: LinkPreviewError.invalidInput) + .eraseToAnyPublisher() } - let (promise, resolver) = Promise.pending() - DispatchQueue.main.async { - _ = ProxiedContentDownloader.defaultDownloader.requestAsset( + return 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 { + .tryMap { asset, _ -> Data in let imageSize = NSData.imageSize(forFilePath: asset.filePath, mimeType: imageMimeType) guard imageSize.width > 0, imageSize.height > 0 else { - return Promise(error: LinkPreviewError.invalidContent) + throw LinkPreviewError.invalidContent } - let data = try Data(contentsOf: URL(fileURLWithPath: asset.filePath)) - - guard let srcImage = UIImage(data: data) else { - return Promise(error: LinkPreviewError.invalidContent) + guard let data: Data = try? Data(contentsOf: URL(fileURLWithPath: asset.filePath)) else { + throw LinkPreviewError.assertionFailure } + + guard let srcImage = UIImage(data: data) else { throw 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) } + if + imageMimeType == OWSMimeTypeImageGif && + NSData(data: data).ows_isValidImage(withMimeType: OWSMimeTypeImageGif) + { + return 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) + throw LinkPreviewError.invalidContent } - return Promise.value(dstData) + return 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) - } + guard + let dstImage = srcImage.resized(withMaxDimensionPoints: maxImageSize), + let dstData = dstImage.jpegData(compressionQuality: 0.8) + else { throw LinkPreviewError.invalidContent } - return Promise.value(dstData) + return dstData } - catch { - return Promise(error: LinkPreviewError.assertionFailure) - } - } + .mapError { _ -> Error in LinkPreviewError.couldNotDownload } + .eraseToAnyPublisher() } private static func isRetryable(error: Error) -> Bool { diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 8f91813ec..55da87c1d 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -8,7 +8,7 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco public static var databaseTableName: String { "openGroup" } internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id]) private static let thread = belongsTo(SessionThread.self, using: threadForeignKey) - private static let members = hasMany(GroupMember.self, using: GroupMember.openGroupForeignKey) + public static let members = hasMany(GroupMember.self, using: GroupMember.openGroupForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { @@ -61,6 +61,9 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco static let all: Permissions = [ .read, .write, .upload ] } + /// The Community public key takes up 32 bytes + static let pubkeyByteLength: Int = 32 + public var id: String { threadId } // Identifiable /// The id for the thread this open group belongs to @@ -219,10 +222,6 @@ public extension OpenGroup { // Always force the server to lowercase return "\(server.lowercased()).\(roomToken)" } - - static func urlFor(server: String, roomToken: String, publicKey: String) -> String { - return "\(server)/\(roomToken)?public_key=\(publicKey)" - } } extension OpenGroup: CustomStringConvertible, CustomDebugStringConvertible { diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 0a32ea093..007a6f10c 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -3,9 +3,10 @@ import Foundation import GRDB import DifferenceKit -import SignalCoreKit import SessionUtilitiesKit +/// This type is duplicate in both the database and within the SessionUtil config so should only ever have it's data changes via the +/// `updateAllAndConfig` function. Updating it elsewhere could result in issues with syncing data between devices public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible, CustomStringConvertible, Differentiable { public static var databaseTableName: String { "profile" } internal static let interactionForeignKey = ForeignKey([Columns.id], to: [Interaction.Columns.authorId]) @@ -19,11 +20,13 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco case id case name + case lastNameUpdate case nickname case profilePictureUrl case profilePictureFileName case profileEncryptionKey + case lastProfilePictureUpdate } /// The id for the user that owns the profile (Note: This could be a sessionId, a blindedId or some future variant) @@ -32,6 +35,9 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco /// 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 + /// The timestamp (in seconds since epoch) that the name was last updated + public let lastNameUpdate: TimeInterval + /// A custom name for the profile set by the current user public let nickname: String? @@ -42,24 +48,31 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco public let profilePictureFileName: String? /// The key with which the profile is encrypted. - public let profileEncryptionKey: OWSAES256Key? + public let profileEncryptionKey: Data? + + /// The timestamp (in seconds since epoch) that the profile picture was last updated + public let lastProfilePictureUpdate: TimeInterval // MARK: - Initialization public init( id: String, name: String, + lastNameUpdate: TimeInterval, nickname: String? = nil, profilePictureUrl: String? = nil, profilePictureFileName: String? = nil, - profileEncryptionKey: OWSAES256Key? = nil + profileEncryptionKey: Data? = nil, + lastProfilePictureUpdate: TimeInterval ) { self.id = id self.name = name + self.lastNameUpdate = lastNameUpdate self.nickname = nickname self.profilePictureUrl = profilePictureUrl self.profilePictureFileName = profilePictureFileName self.profileEncryptionKey = profileEncryptionKey + self.lastProfilePictureUpdate = lastProfilePictureUpdate } // MARK: - Description @@ -68,7 +81,7 @@ public struct Profile: Codable, Identifiable, Equatable, Hashable, FetchableReco """ Profile( name: \(name), - profileKey: \(profileEncryptionKey?.keyData.description ?? "null"), + profileKey: \(profileEncryptionKey?.description ?? "null"), profilePictureUrl: \(profilePictureUrl ?? "null") ) """ @@ -81,7 +94,7 @@ public extension Profile { init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - var profileKey: OWSAES256Key? + var profileKey: Data? var profilePictureUrl: String? // If we have both a `profileKey` and a `profilePicture` then the key MUST be valid @@ -89,22 +102,19 @@ public extension Profile { 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 + profileKey = profileKeyData profilePictureUrl = profilePictureUrlValue } self = Profile( id: try container.decode(String.self, forKey: .id), name: try container.decode(String.self, forKey: .name), + lastNameUpdate: try container.decode(TimeInterval.self, forKey: .lastNameUpdate), nickname: try? container.decode(String.self, forKey: .nickname), profilePictureUrl: profilePictureUrl, profilePictureFileName: try? container.decode(String.self, forKey: .profilePictureFileName), - profileEncryptionKey: profileKey + profileEncryptionKey: profileKey, + lastProfilePictureUpdate: try container.decode(TimeInterval.self, forKey: .lastProfilePictureUpdate) ) } @@ -113,10 +123,12 @@ public extension Profile { 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) + try container.encode(lastNameUpdate, forKey: .lastNameUpdate) + try container.encodeIfPresent(nickname, forKey: .nickname) + try container.encodeIfPresent(profilePictureUrl, forKey: .profilePictureUrl) + try container.encodeIfPresent(profilePictureFileName, forKey: .profilePictureFileName) + try container.encodeIfPresent(profileEncryptionKey, forKey: .profileEncryptionKey) + try container.encode(lastProfilePictureUpdate, forKey: .lastProfilePictureUpdate) } } @@ -126,27 +138,25 @@ 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 profileKey: Data? var profilePictureUrl: String? + let sentTimestamp: TimeInterval = (proto.hasTimestamp ? (TimeInterval(proto.timestamp) / 1000) : 0) // 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 + profileKey = profileKeyData profilePictureUrl = profileProto.profilePicture } return Profile( id: id, name: displayName, + lastNameUpdate: sentTimestamp, nickname: nil, profilePictureUrl: profilePictureUrl, profilePictureFileName: nil, - profileEncryptionKey: profileKey + profileEncryptionKey: profileKey, + lastProfilePictureUpdate: sentTimestamp ) } @@ -155,8 +165,8 @@ public extension Profile { let profileProto = SNProtoLokiProfile.builder() profileProto.setDisplayName(name) - if let profileKey: OWSAES256Key = profileEncryptionKey, let profilePictureUrl: String = profilePictureUrl { - dataMessageProto.setProfileKey(profileKey.keyData) + if let profileKey: Data = profileEncryptionKey, let profilePictureUrl: String = profilePictureUrl { + dataMessageProto.setProfileKey(profileKey) profileProto.setProfilePicture(profilePictureUrl) } @@ -171,26 +181,6 @@ public extension Profile { } } -// 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 { @@ -247,10 +237,12 @@ public extension Profile { return Profile( id: id, name: "", + lastNameUpdate: 0, nickname: nil, profilePictureUrl: nil, profilePictureFileName: nil, - profileEncryptionKey: nil + profileEncryptionKey: nil, + lastProfilePictureUpdate: 0 ) } @@ -258,43 +250,21 @@ public extension Profile { /// /// **Note:** This method intentionally does **not** save the newly created Profile, /// it will need to be explicitly saved after calling - static func fetchOrCreateCurrentUser(dependencies: Dependencies = Dependencies()) -> Profile { - var userPublicKey: String = "" - - let exisingProfile: Profile? = dependencies.storage.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 { + static func fetchOrCreateCurrentUser(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> Profile { let userPublicKey: String = getUserHexEncodedPublicKey(db) + guard let db: Database = db else { + return dependencies.storage + .read { db in fetchOrCreateCurrentUser(db) } + .defaulting(to: defaultFor(userPublicKey)) + } + 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, @@ -353,42 +323,12 @@ public extension Profile { } switch threadVariant { - case .contact, .closedGroup: return name + case .contact, .legacyGroup, .group: return name - case .openGroup: + case .community: // 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/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 406dc4df4..958e7fc30 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -11,7 +11,7 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, 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( + public static let disappearingMessagesConfiguration = hasOne( DisappearingMessagesConfiguration.self, using: DisappearingMessagesConfiguration.threadForeignKey ) @@ -32,12 +32,15 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, case notificationSound case mutedUntilTimestamp case onlyNotifyForMentions + case markedAsUnread + case pinnedPriority } - public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible { + public enum Variant: Int, Codable, Hashable, DatabaseValueConvertible, CaseIterable { case contact - case closedGroup - case openGroup + case legacyGroup + case community + case group } /// Unique identifier for a thread (formerly known as uniqueId) @@ -58,7 +61,8 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, public let shouldBeVisible: Bool /// A flag indicating whether the thread is pinned - public let isPinned: Bool + @available(*, unavailable, message: "use 'pinnedPriority' instead") + public let isPinned: Bool = false /// The value the user started entering into the input field before they left the conversation screen public let messageDraft: String? @@ -74,6 +78,12 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, /// A flag indicating whether the thread should only notify for mentions public let onlyNotifyForMentions: Bool + /// A flag indicating whether this thread has been manually marked as unread by the user + public let markedAsUnread: Bool? + + /// A value indicating the priority of this conversation within the pinned conversations + public let pinnedPriority: Int32? + // MARK: - Relationships public var contact: QueryInterfaceRequest { @@ -111,17 +121,22 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, messageDraft: String? = nil, notificationSound: Preferences.Sound? = nil, mutedUntilTimestamp: TimeInterval? = nil, - onlyNotifyForMentions: Bool = false + onlyNotifyForMentions: Bool = false, + markedAsUnread: Bool? = false, + pinnedPriority: Int32? = nil ) { 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 + self.markedAsUnread = markedAsUnread + self.pinnedPriority = ((pinnedPriority ?? 0) > 0 ? pinnedPriority : + (isPinned ? 1 : 0) + ) } // MARK: - Custom Database Interaction @@ -131,57 +146,215 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, } } -// 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 + /// Fetches or creates a SessionThread with the specified id, variant and visible state /// /// **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 { + @discardableResult static func fetchOrCreate( + _ db: Database, + id: ID, + variant: Variant, + shouldBeVisible: Bool? + ) throws -> SessionThread { guard let existingThread: SessionThread = try? fetchOne(db, id: id) else { - return try SessionThread(id: id, variant: variant) - .saved(db) + return try SessionThread( + id: id, + variant: variant, + shouldBeVisible: (shouldBeVisible ?? false) + ).saved(db) } - return existingThread + // If the `shouldBeVisible` state matches then we can finish early + guard + let desiredVisibility: Bool = shouldBeVisible, + existingThread.shouldBeVisible != desiredVisibility + else { return existingThread } + + // Update the `shouldBeVisible` state + try SessionThread + .filter(id: id) + .updateAllAndConfig( + db, + SessionThread.Columns.shouldBeVisible.set(to: shouldBeVisible) + ) + + // Retrieve the updated thread and return it (we don't recursively call this method + // just in case something weird happened and the above update didn't work, as that + // would result in an infinite loop) + return (try fetchOne(db, id: id)) + .defaulting( + to: try SessionThread(id: id, variant: variant, shouldBeVisible: desiredVisibility) + .saved(db) + ) } - func isMessageRequest(_ db: Database, includeNonVisible: Bool = false) -> Bool { + static func canSendReadReceipt( + _ db: Database, + threadId: String, + threadVariant maybeThreadVariant: SessionThread.Variant? = nil, + isBlocked maybeIsBlocked: Bool? = nil, + isMessageRequest maybeIsMessageRequest: Bool? = nil + ) throws -> Bool { + let threadVariant: SessionThread.Variant = try { + try maybeThreadVariant ?? + SessionThread + .filter(id: threadId) + .select(.variant) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db, orThrow: StorageError.objectNotFound) + }() + let threadIsBlocked: Bool = try { + try maybeIsBlocked ?? + ( + threadVariant == .contact && + Contact + .filter(id: threadId) + .select(.isBlocked) + .asRequest(of: Bool.self) + .fetchOne(db, orThrow: StorageError.objectNotFound) + ) + }() + let threadIsMessageRequest: Bool = SessionThread + .filter(id: threadId) + .filter( + SessionThread.isMessageRequest( + userPublicKey: getUserHexEncodedPublicKey(db), + includeNonVisible: true + ) + ) + .isNotEmpty(db) + 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 + !threadIsBlocked && + !threadIsMessageRequest ) } + + @available(*, unavailable, message: "should not be used until pin re-ordering is built") + static func refreshPinnedPriorities(_ db: Database, adding threadId: String) throws { + struct PinnedPriority: TableRecord, ColumnExpressible { + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case id + case rowIndex + } + } + + let thread: TypedTableAlias = TypedTableAlias() + let pinnedPriority: TypedTableAlias = TypedTableAlias() + let rowIndexLiteral: SQL = SQL(stringLiteral: PinnedPriority.Columns.rowIndex.name) + let pinnedPriorityLiteral: SQL = SQL(stringLiteral: SessionThread.Columns.pinnedPriority.name) + + try db.execute(literal: """ + WITH \(PinnedPriority.self) AS ( + SELECT + \(thread[.id]), + ROW_NUMBER() OVER ( + ORDER BY \(SQL("\(thread[.id]) != \(threadId)")), + \(thread[.pinnedPriority]) ASC + ) AS \(rowIndexLiteral) + FROM \(SessionThread.self) + WHERE + \(thread[.pinnedPriority]) > 0 OR + \(SQL("\(thread[.id]) = \(threadId)")) + ) + + UPDATE \(SessionThread.self) + SET \(pinnedPriorityLiteral) = ( + SELECT \(pinnedPriority[.rowIndex]) + FROM \(PinnedPriority.self) + WHERE \(pinnedPriority[.id]) = \(thread[.id]) + ) + """) + } + + static func deleteOrLeave( + _ db: Database, + threadId: String, + threadVariant: Variant, + groupLeaveType: ClosedGroup.LeaveType, + calledFromConfigHandling: Bool + ) throws { + try deleteOrLeave( + db, + threadIds: [threadId], + threadVariant: threadVariant, + groupLeaveType: groupLeaveType, + calledFromConfigHandling: calledFromConfigHandling + ) + } + + static func deleteOrLeave( + _ db: Database, + threadIds: [String], + threadVariant: Variant, + groupLeaveType: ClosedGroup.LeaveType, + calledFromConfigHandling: Bool + ) throws { + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + let remainingThreadIds: [String] = threadIds.filter { $0 != currentUserPublicKey } + + switch (threadVariant, groupLeaveType) { + case (.contact, _): + // We need to custom handle the 'Note to Self' conversation (it should just be + // hidden rather than deleted + if threadIds.contains(currentUserPublicKey) { + _ = try Interaction + .filter(Interaction.Columns.threadId == currentUserPublicKey) + .deleteAll(db) + + _ = try SessionThread + .filter(id: currentUserPublicKey) + .updateAllAndConfig( + db, + SessionThread.Columns.pinnedPriority.set(to: 0), + SessionThread.Columns.shouldBeVisible.set(to: false) + ) + return + } + + // If this wasn't called from config handling then we need to hide the conversation + if !calledFromConfigHandling { + try SessionUtil + .hide(db, contactIds: threadIds) + } + + _ = try SessionThread + .filter(ids: remainingThreadIds) + .deleteAll(db) + + case (.legacyGroup, .standard), (.group, .standard): + try threadIds.forEach { threadId in + try MessageSender + .leave( + db, + groupPublicKey: threadId, + deleteThread: true + ) + } + + case (.legacyGroup, .silent), (.legacyGroup, .forced), (.group, .forced), (.group, .silent): + try ClosedGroup.removeKeysAndUnsubscribe( + db, + threadIds: threadIds, + removeGroupData: true, + calledFromConfigHandling: calledFromConfigHandling + ) + + case (.community, _): + threadIds.forEach { threadId in + OpenGroupManager.shared.delete( + db, + openGroupId: threadId, + calledFromConfigHandling: calledFromConfigHandling + ) + } + } + } } // MARK: - Convenience @@ -244,6 +417,38 @@ public extension SessionThread { ).sqlExpression } + func isMessageRequest(_ db: Database, includeNonVisible: Bool = false) -> Bool { + return SessionThread.isMessageRequest( + id: id, + variant: variant, + currentUserPublicKey: getUserHexEncodedPublicKey(db), + shouldBeVisible: shouldBeVisible, + contactIsApproved: (try? Contact + .filter(id: id) + .select(.isApproved) + .asRequest(of: Bool.self) + .fetchOne(db)) + .defaulting(to: false), + includeNonVisible: includeNonVisible + ) + } + + static func isMessageRequest( + id: String, + variant: SessionThread.Variant?, + currentUserPublicKey: String, + shouldBeVisible: Bool?, + contactIsApproved: Bool?, + includeNonVisible: Bool = false + ) -> Bool { + return ( + (includeNonVisible || shouldBeVisible == true) && + variant == .contact && + id != currentUserPublicKey && // Note to self + ((contactIsApproved ?? false) == false) + ) + } + func isNoteToSelf(_ db: Database? = nil) -> Bool { return ( variant == .contact && @@ -304,8 +509,8 @@ public extension SessionThread { profile: Profile? = nil ) -> String { switch variant { - case .closedGroup: return (closedGroupName ?? "Unknown Group") - case .openGroup: return (openGroupName ?? "Unknown Group") + case .legacyGroup, .group: return (closedGroupName ?? "Unknown Group") + case .community: return (openGroupName ?? "Unknown Community") case .contact: guard !isNoteToSelf else { return "NOTE_TO_SELF".localized() } guard let profile: Profile = profile else { @@ -319,12 +524,13 @@ public extension SessionThread { static func getUserHexEncodedBlindedKey( _ db: Database? = nil, threadId: String, - threadVariant: Variant + threadVariant: Variant, + blindingPrefix: SessionId.Prefix ) -> String? { - guard threadVariant == .openGroup else { return nil } + guard threadVariant == .community else { return nil } guard let db: Database = db else { return Storage.shared.read { db in - getUserHexEncodedBlindedKey(db, threadId: threadId, threadVariant: threadVariant) + getUserHexEncodedBlindedKey(db, threadId: threadId, threadVariant: threadVariant, blindingPrefix: blindingPrefix) } } @@ -335,7 +541,7 @@ public extension SessionThread { } guard - let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), + let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), let openGroupInfo: OpenGroupInfo = try? OpenGroup .filter(id: threadId) .select(.publicKey, .server) @@ -355,14 +561,14 @@ public extension SessionThread { let sodium: Sodium = Sodium() - let blindedKeyPair: Box.KeyPair? = sodium.blindedKeyPair( + let blindedKeyPair: KeyPair? = sodium.blindedKeyPair( serverPublicKey: openGroupInfo.publicKey, edKeyPair: userEdKeyPair, genericHash: sodium.getGenericHash() ) return blindedKeyPair.map { keyPair -> String in - SessionId(.blinded, publicKey: keyPair.publicKey).hexString + SessionId(blindingPrefix, publicKey: keyPair.publicKey).hexString } } } diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index 964a09ffe..73c1ccead 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -1,18 +1,17 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit +import Combine import SessionSnodeKit import SessionUtilitiesKit -@objc(SNFileServerAPI) -public final class FileServerAPI: NSObject { +public enum FileServerAPI { // MARK: - Settings - @objc public static let oldServer = "http://88.99.175.227" + 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 server = "http://filev2.getsession.org" public static let serverPublicKey = "da21e1d886c6fbaea313f75298bd64aab03a97ce985b46bb2dad9f2089c8ee59" /// **Note:** The max file size is 10,000,000 bytes (rather than 10MiB which would be `(10 * 1024 * 1024)`), 10,000,000 @@ -25,7 +24,7 @@ public final class FileServerAPI: NSObject { // MARK: - File Storage - public static func upload(_ file: Data) -> Promise { + public static func upload(_ file: Data) -> AnyPublisher { let request = Request( method: .post, server: server, @@ -38,10 +37,10 @@ public final class FileServerAPI: NSObject { ) return send(request, serverPublicKey: serverPublicKey, timeout: FileServerAPI.fileUploadTimeout) - .decoded(as: FileUploadResponse.self, on: .global(qos: .userInitiated)) + .decoded(as: FileUploadResponse.self) } - public static func download(_ fileId: String, useOldServer: Bool) -> Promise { + public static func download(_ fileId: String, useOldServer: Bool) -> AnyPublisher { let serverPublicKey: String = (useOldServer ? oldServerPublicKey : serverPublicKey) let request = Request( server: (useOldServer ? oldServer : server), @@ -51,7 +50,7 @@ public final class FileServerAPI: NSObject { return send(request, serverPublicKey: serverPublicKey, timeout: FileServerAPI.fileDownloadTimeout) } - public static func getVersion(_ platform: String) -> Promise { + public static func getVersion(_ platform: String) -> AnyPublisher { let request = Request( server: server, endpoint: .sessionVersion, @@ -60,9 +59,10 @@ public final class FileServerAPI: NSObject { ] ) - return send(request, serverPublicKey: serverPublicKey, timeout: HTTP.timeout) - .decoded(as: VersionResponse.self, on: .global(qos: .userInitiated)) + return send(request, serverPublicKey: serverPublicKey, timeout: HTTP.defaultTimeout) + .decoded(as: VersionResponse.self) .map { response in response.version } + .eraseToAnyPublisher() } // MARK: - Convenience @@ -71,14 +71,15 @@ public final class FileServerAPI: NSObject { _ request: Request, serverPublicKey: String, timeout: TimeInterval - ) -> Promise { + ) -> AnyPublisher { let urlRequest: URLRequest do { urlRequest = try request.generateUrlRequest() } catch { - return Promise(error: error) + return Fail(error: error) + .eraseToAnyPublisher() } return OnionRequestAPI @@ -88,10 +89,11 @@ public final class FileServerAPI: NSObject { with: serverPublicKey, timeout: timeout ) - .map2 { _, response in - guard let response: Data = response else { throw HTTP.Error.parsingFailed } + .tryMap { _, response -> Data in + guard let response: Data = response else { throw HTTPError.parsingFailed } return response } + .eraseToAnyPublisher() } } diff --git a/SessionMessagingKit/File Server/Types/FSEndpoint.swift b/SessionMessagingKit/File Server/Types/FSEndpoint.swift index 5e242bea8..edc555f4f 100644 --- a/SessionMessagingKit/File Server/Types/FSEndpoint.swift +++ b/SessionMessagingKit/File Server/Types/FSEndpoint.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit extension FileServerAPI { public enum Endpoint: EndpointType { @@ -8,7 +9,7 @@ extension FileServerAPI { case fileIndividual(fileId: String) case sessionVersion - var path: String { + public var path: String { switch self { case .file: return "file" case .fileIndividual(let fileId): return "file/\(fileId)" diff --git a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift index cbaa8e092..1b64721d5 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift @@ -1,7 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit +import Combine import SessionUtilitiesKit import SessionSnodeKit import SignalCoreKit @@ -43,11 +43,11 @@ public enum AttachmentDownloadJob: JobExecutor { // if an attachment ends up stuck in a "downloading" state incorrectly guard attachment.state != .downloading else { let otherCurrentJobAttachmentIds: Set = dependencies.jobRunner - .detailsFor(state: .running, variant: .attachmentDownload) + .jobInfoFor(state: .running, variant: .attachmentDownload) .filter { key, _ in key != job.id } .values - .compactMap { data -> String? in - guard let data: Data = data else { return nil } + .compactMap { info -> String? in + guard let data: Data = info.detailsData else { return nil } return (try? JSONDecoder().decode(Details.self, from: data))? .attachmentId @@ -85,33 +85,52 @@ public enum AttachmentDownloadJob: JobExecutor { 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 + Just(attachment.downloadUrl) + .setFailureType(to: Error.self) + .tryFlatMap { maybeDownloadUrl -> AnyPublisher in + guard + let downloadUrl: String = maybeDownloadUrl, + let fileId: String = Attachment.fileId(for: downloadUrl) + else { throw AttachmentDownloadError.invalidUrl } + + return Storage.shared + .readPublisher { db -> OpenGroupAPI.PreparedSendData? in + try OpenGroup.fetchOne(db, id: threadId) + .map { openGroup in + try OpenGroupAPI + .preparedDownloadFile( + db, + fileId: fileId, + from: openGroup.roomToken, + on: openGroup.server + ) + } + } + .flatMap { maybePreparedSendData -> AnyPublisher in + guard let preparedSendData: OpenGroupAPI.PreparedSendData = maybePreparedSendData else { + return FileServerAPI + .download( + fileId, + useOldServer: downloadUrl.contains(FileServerAPI.oldServer) + ) + .eraseToAnyPublisher() + } + + return OpenGroupAPI + .send(data: preparedSendData) + .map { _, data in data } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .subscribe(on: queue) + .receive(on: queue) + .tryMap { data -> Void in + // Store the encrypted data temporarily try data.write(to: temporaryFileUrl, options: .atomic) + // Decrypt the data let plaintext: Data = try { guard let key: Data = attachment.encryptionKey, @@ -128,76 +147,80 @@ public enum AttachmentDownloadJob: JobExecutor { ) }() + // Write the data to disk guard try attachment.write(data: plaintext) else { throw AttachmentDownloadError.failedToSaveFile } - return Promise.value(()) + return () } - .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 - dependencies.storage.write { db in - _ = try attachment - .with( - state: .downloaded, - creationTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000), - localRelativeFilePath: ( - attachment.localRelativeFilePath ?? - Attachment.localRelativeFilePath(from: attachment.originalFilePath) - ) - ) - .saved(db) + .sinkUntilComplete( + receiveCompletion: { result in + // Remove the temporary file + OWSFileSystem.deleteFile(temporaryFileUrl.path) + + switch result { + case .finished: + /// 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 + dependencies.storage.write { db in + _ = try attachment + .with( + state: .downloaded, + creationTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000), + localRelativeFilePath: ( + attachment.localRelativeFilePath ?? + Attachment.localRelativeFilePath(from: attachment.originalFilePath) + ) + ) + .saved(db) + } + + success(job, false, dependencies) + + case .failure(let error): + 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 + dependencies.storage.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, dependencies) + } } - - success(job, false, dependencies) - } - .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 - dependencies.storage.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, dependencies) - } + ) } } diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index c0a2c9503..5ed000623 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -1,8 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SignalCoreKit import SessionUtilitiesKit @@ -32,49 +32,80 @@ public enum AttachmentUploadJob: JobExecutor { return (attachment, try OpenGroup.fetchOne(db, id: threadId)) }) else { - failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) - return + SNLog("[AttachmentUploadJob] Failed due to missing details") + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } // If the original interaction no longer exists then don't bother uploading the attachment (ie. the // message was deleted before it even got sent) guard dependencies.storage.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { - failure(job, StorageError.objectNotFound, true, dependencies) - return + SNLog("[AttachmentUploadJob] Failed due to missing interaction") + return failure(job, StorageError.objectNotFound, true, dependencies) } // If the attachment is still pending download the hold off on running this job guard attachment.state != .pendingDownload && attachment.state != .downloading else { - deferred(job, dependencies) - return + SNLog("[AttachmentUploadJob] Deferred as attachment is still being downloaded") + return deferred(job, dependencies) + } + + // If this upload is related to sending a message then trigger the 'handleMessageWillSend' logic + // as if this is a retry the logic wouldn't run until after the upload has completed resulting in + // a potentially incorrect delivery status + dependencies.storage.write { db in + guard + let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), + let sendJobDetails: Data = sendJob.details, + let details: MessageSendJob.Details = try? JSONDecoder() + .decode(MessageSendJob.Details.self, from: sendJobDetails) + else { return } + + MessageSender.handleMessageWillSend( + db, + message: details.message, + interactionId: interactionId, + isSyncMessage: details.isSyncMessage + ) } // 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 - SNLog("[AttachmentUpload] Started for message \(interactionId) (\(attachment.byteCount) bytes)") - - if let openGroup: OpenGroup = openGroup { - return OpenGroupAPI - .uploadFile( - db, - bytes: data.bytes, - to: openGroup.roomToken, - on: openGroup.server - ) - .map { _, response -> String in response.id } + attachment + .upload(to: (openGroup.map { .openGroup($0) } ?? .fileServer)) + .subscribe(on: queue) + .receive(on: queue) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .failure(let error): + // If this upload is related to sending a message then trigger the + // 'handleFailedMessageSend' logic as we want to ensure the message + // has the correct delivery status + dependencies.storage.read { db in + guard + let sendJob: Job = try Job.fetchOne(db, id: details.messageSendJobId), + let sendJobDetails: Data = sendJob.details, + let details: MessageSendJob.Details = try? JSONDecoder() + .decode(MessageSendJob.Details.self, from: sendJobDetails) + else { return } + + MessageSender.handleFailedMessageSend( + db, + message: details.message, + with: .other(error), + interactionId: interactionId, + isSyncMessage: details.isSyncMessage + ) + } + + SNLog("[AttachmentUploadJob] Failed due to error: \(error)") + failure(job, error, false, dependencies) + + case .finished: success(job, false, dependencies) + } } - - return FileServerAPI.upload(data) - .map { response -> String in response.id } - }, - encrypt: (openGroup == nil), - success: { _ in success(job, false, dependencies) }, - failure: { error in failure(job, error, false, dependencies) } - ) + ) } } diff --git a/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift new file mode 100644 index 000000000..38a3b2dcb --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/ConfigMessageReceiveJob.swift @@ -0,0 +1,85 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public enum ConfigMessageReceiveJob: JobExecutor { + public static var maxFailureCount: Int = 0 + public static var requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = false + + public static func run( + _ job: Job, + queue: DispatchQueue, + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() + ) { + /// When the `configMessageReceive` job fails we want to unblock any `messageReceive` jobs it was blocking + /// to ensure the user isn't losing any messages - this generally _shouldn't_ happen but if it does then having a temporary + /// "outdated" state due to standard messages which would have been invalidated by a config change incorrectly being + /// processed is less severe then dropping a bunch on messages just because they were processed in the same poll as + /// invalid config messages + let removeDependencyOnMessageReceiveJobs: () -> () = { + guard let jobId: Int64 = job.id else { return } + + dependencies.storage.write { db in + try JobDependencies + .filter(JobDependencies.Columns.dependantId == jobId) + .joining( + required: JobDependencies.job + .filter(Job.Columns.variant == Job.Variant.messageReceive) + ) + .deleteAll(db) + } + } + + guard + let detailsData: Data = job.details, + let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) + else { + removeDependencyOnMessageReceiveJobs() + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) + } + + // Ensure no standard messages are sent through this job + guard !details.messages.contains(where: { $0.variant != .sharedConfigMessage }) else { + SNLog("[ConfigMessageReceiveJob] Standard messages incorrectly sent to the 'configMessageReceive' job") + removeDependencyOnMessageReceiveJobs() + return failure(job, MessageReceiverError.invalidMessage, true, dependencies) + } + + var lastError: Error? + let sharedConfigMessages: [SharedConfigMessage] = details.messages + .compactMap { $0.message as? SharedConfigMessage } + + dependencies.storage.write { db in + // Send any SharedConfigMessages to the SessionUtil to handle it + do { + try SessionUtil.handleConfigMessages( + db, + messages: sharedConfigMessages, + publicKey: (job.threadId ?? "") + ) + } + catch { lastError = error } + } + + // Handle the result + switch lastError { + case .some(let error): + removeDependencyOnMessageReceiveJobs() + failure(job, error, true, dependencies) + + case .none: success(job, false, dependencies) + } + } +} + +// MARK: - ConfigMessageReceiveJob.Details + +extension ConfigMessageReceiveJob { + typealias Details = MessageReceiveJob.Details +} diff --git a/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift new file mode 100644 index 000000000..8c471b4db --- /dev/null +++ b/SessionMessagingKit/Jobs/Types/ConfigurationSyncJob.swift @@ -0,0 +1,307 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import SessionSnodeKit +import SessionUtilitiesKit + +public enum ConfigurationSyncJob: JobExecutor { + public static let maxFailureCount: Int = -1 + public static let requiresThreadId: Bool = true + public static let requiresInteractionId: Bool = false + private static let maxRunFrequency: TimeInterval = 3 + + public static func run( + _ job: Job, + queue: DispatchQueue, + success: @escaping (Job, Bool, Dependencies) -> (), + failure: @escaping (Job, Error?, Bool, Dependencies) -> (), + deferred: @escaping (Job, Dependencies) -> (), + dependencies: Dependencies = Dependencies() + ) { + guard + SessionUtil.userConfigsEnabled, + Identity.userCompletedRequiredOnboarding() + else { return success(job, true, dependencies) } + + // It's possible for multiple ConfigSyncJob's with the same target (user/group) to try to run at the + // same time since as soon as one is started we will enqueue a second one, rather than adding dependencies + // between the jobs we just continue to defer the subsequent job while the first one is running in + // order to prevent multiple configurationSync jobs with the same target from running at the same time + guard + dependencies + .jobRunner + .jobInfoFor(state: .running, variant: .configurationSync) + .filter({ key, info in + key != job.id && // Exclude this job + info.threadId == job.threadId // Exclude jobs for different ids + }) + .isEmpty + else { + // Defer the job to run 'maxRunFrequency' from when this one ran (if we don't it'll try start + // it again immediately which is pointless) + let updatedJob: Job? = dependencies.storage.write { db in + try job + .with(nextRunTimestamp: Date().timeIntervalSince1970 + maxRunFrequency) + .saved(db) + } + + SNLog("[ConfigurationSyncJob] For \(job.threadId ?? "UnknownId") deferred due to in progress job") + return deferred(updatedJob ?? job, dependencies) + } + + // 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 + let publicKey: String = job.threadId, + let pendingConfigChanges: [SessionUtil.OutgoingConfResult] = Storage.shared + .read({ db in try SessionUtil.pendingChanges(db, publicKey: publicKey) }) + else { + SNLog("[ConfigurationSyncJob] For \(job.threadId ?? "UnknownId") failed due to invalid data") + return failure(job, StorageError.generic, false, dependencies) + } + + // If there are no pending changes then the job can just complete (next time something + // is updated we want to try and run immediately so don't scuedule another run in this case) + guard !pendingConfigChanges.isEmpty else { + SNLog("[ConfigurationSyncJob] For \(publicKey) completed with no pending changes") + return success(job, true, dependencies) + } + + // Identify the destination and merge all obsolete hashes into a single set + let destination: Message.Destination = (publicKey == getUserHexEncodedPublicKey() ? + Message.Destination.contact(publicKey: publicKey) : + Message.Destination.closedGroup(groupPublicKey: publicKey) + ) + let allObsoleteHashes: Set = pendingConfigChanges + .map { $0.obsoleteHashes } + .reduce([], +) + .asSet() + let jobStartTimestamp: TimeInterval = Date().timeIntervalSince1970 + SNLog("[ConfigurationSyncJob] For \(publicKey) started with \(pendingConfigChanges.count) change\(pendingConfigChanges.count == 1 ? "" : "s")") + + dependencies.storage + .readPublisher { db in + try pendingConfigChanges.map { change -> MessageSender.PreparedSendData in + try MessageSender.preparedSendData( + db, + message: change.message, + to: destination, + namespace: change.namespace, + interactionId: nil + ) + } + } + .flatMap { (changes: [MessageSender.PreparedSendData]) -> AnyPublisher in + SnodeAPI + .sendConfigMessages( + changes.compactMap { change in + guard + let namespace: SnodeAPI.Namespace = change.namespace, + let snodeMessage: SnodeMessage = change.snodeMessage + else { return nil } + + return (snodeMessage, namespace) + }, + allObsoleteHashes: Array(allObsoleteHashes) + ) + } + .subscribe(on: queue) + .receive(on: queue) + .map { (response: HTTP.BatchResponse) -> [ConfigDump] in + /// The number of responses returned might not match the number of changes sent but they will be returned + /// in the same order, this means we can just `zip` the two arrays as it will take the smaller of the two and + /// correctly align the response to the change + zip(response.responses, pendingConfigChanges) + .compactMap { (subResponse: Decodable, change: SessionUtil.OutgoingConfResult) in + /// If the request wasn't successful then just ignore it (the next time we sync this config we will try + /// to send the changes again) + guard + let typedResponse: HTTP.BatchSubResponse = (subResponse as? HTTP.BatchSubResponse), + 200...299 ~= typedResponse.code, + !typedResponse.failedToParseBody, + let sendMessageResponse: SendMessagesResponse = typedResponse.body + else { return nil } + + /// Since this change was successful we need to mark it as pushed and generate any config dumps + /// which need to be stored + return SessionUtil.markingAsPushed( + message: change.message, + serverHash: sendMessageResponse.hash, + publicKey: publicKey + ) + } + } + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: SNLog("[ConfigurationSyncJob] For \(publicKey) completed") + case .failure(let error): + SNLog("[ConfigurationSyncJob] For \(publicKey) failed due to error: \(error)") + failure(job, error, false, dependencies) + } + }, + receiveValue: { (configDumps: [ConfigDump]) in + // Flag to indicate whether the job should be finished or will run again + var shouldFinishCurrentJob: Bool = false + + // Lastly we need to save the updated dumps to the database + let updatedJob: Job? = dependencies.storage.write { db in + // Save the updated dumps to the database + try configDumps.forEach { try $0.save(db) } + + // When we complete the 'ConfigurationSync' job we want to immediately schedule + // another one with a 'nextRunTimestamp' set to the 'maxRunFrequency' value to + // throttle the config sync requests + let nextRunTimestamp: TimeInterval = (jobStartTimestamp + maxRunFrequency) + + // If another 'ConfigurationSync' 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.configurationSync) + .filter(Job.Columns.threadId == publicKey) + .order(Job.Columns.nextRunTimestamp.asc) + .fetchOne(db) + { + // If the next job isn't currently running then delay it's start time + // until the 'nextRunTimestamp' + if !dependencies.jobRunner.isCurrentlyRunning(existingJob) { + _ = try existingJob + .with(nextRunTimestamp: nextRunTimestamp) + .saved(db) + } + + // If there is another job then we should finish this one + shouldFinishCurrentJob = true + return job + } + + return try job + .with(nextRunTimestamp: nextRunTimestamp) + .saved(db) + } + + success((updatedJob ?? job), shouldFinishCurrentJob, dependencies) + } + ) + } +} + +// MARK: - Convenience + +public extension ConfigurationSyncJob { + static func enqueue( + _ db: Database, + publicKey: String, + dependencies: Dependencies = Dependencies() + ) { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled(db) else { + // If we don't have a userKeyPair (or name) yet then there is no need to sync the + // configuration as the user doesn't fully exist yet (this will get triggered on + // the first launch of a fresh install due to the migrations getting run and a few + // times during onboarding) + guard + Identity.userCompletedRequiredOnboarding(db), + let legacyConfigMessage: Message = try? ConfigurationMessage.getCurrent(db) + else { return } + + let publicKey: String = getUserHexEncodedPublicKey(db) + + dependencies.jobRunner.add( + db, + job: Job( + variant: .messageSend, + threadId: publicKey, + details: MessageSendJob.Details( + destination: Message.Destination.contact(publicKey: publicKey), + message: legacyConfigMessage + ) + ), + canStartJob: true, + dependencies: dependencies + ) + return + } + + // Upsert a config sync job if needed + dependencies.jobRunner.upsert( + db, + job: ConfigurationSyncJob.createIfNeeded(db, publicKey: publicKey, dependencies: dependencies), + canStartJob: true, + dependencies: dependencies + ) + } + + @discardableResult static func createIfNeeded( + _ db: Database, + publicKey: String, + dependencies: Dependencies = Dependencies() + ) -> Job? { + /// The ConfigurationSyncJob will automatically reschedule itself to run again after 3 seconds so if there is an existing + /// job then there is no need to create another instance + /// + /// **Note:** Jobs with different `threadId` values can run concurrently + guard + dependencies.jobRunner + .jobInfoFor(state: .running, variant: .configurationSync) + .filter({ _, info in info.threadId == publicKey }) + .isEmpty, + (try? Job + .filter(Job.Columns.variant == Job.Variant.configurationSync) + .filter(Job.Columns.threadId == publicKey) + .isEmpty(db)) + .defaulting(to: false) + else { return nil } + + // Otherwise create a new job + return Job( + variant: .configurationSync, + behaviour: .recurring, + threadId: publicKey + ) + } + + static func run() -> AnyPublisher { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled else { + return Storage.shared + .writePublisher { db -> MessageSender.PreparedSendData in + // 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.userCompletedRequiredOnboarding(db) else { throw StorageError.generic } + + let publicKey: String = getUserHexEncodedPublicKey(db) + + return try MessageSender.preparedSendData( + db, + message: try ConfigurationMessage.getCurrent(db), + to: Message.Destination.contact(publicKey: publicKey), + namespace: .default, + interactionId: nil + ) + } + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .eraseToAnyPublisher() + } + + // Trigger the job emitting the result when completed + return Deferred { + Future { resolver in + ConfigurationSyncJob.run( + Job(variant: .configurationSync), + queue: .global(qos: .userInitiated), + success: { _, _, _ in resolver(Result.success(())) }, + failure: { _, error, _, _ in resolver(Result.failure(error ?? HTTPError.generic)) }, + deferred: { _, _ in } + ) + } + } + .eraseToAnyPublisher() + } +} diff --git a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift index d1dc38b27..6434b9b52 100644 --- a/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift @@ -21,9 +21,10 @@ public enum DisappearingMessagesJob: JobExecutor { // The 'backgroundTask' gets captured and cleared within the 'completion' block let timestampNowMs: TimeInterval = TimeInterval(SnodeAPI.currentOffsetTimestampMs()) var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: #function) + var numDeleted: Int = -1 - let updatedJob: Job? = dependencies.storage.write { db in - _ = try Interaction + let updatedJob: Job? = Storage.shared.write { db in + numDeleted = try Interaction .filter(Interaction.Columns.expiresStartedAtMs != nil) .filter((Interaction.Columns.expiresStartedAtMs + (Interaction.Columns.expiresInSeconds * 1000)) <= timestampNowMs) .deleteAll(db) @@ -36,6 +37,7 @@ public enum DisappearingMessagesJob: JobExecutor { .saved(db) } + SNLog("[DisappearingMessagesJob] Deleted \(numDeleted) expired messages") success(updatedJob ?? job, false, dependencies) // The 'if' is only there to prevent the "variable never read" warning from showing @@ -59,12 +61,16 @@ public extension DisappearingMessagesJob { .asRequest(of: Double.self) .fetchOne(db) - guard let nextExpirationTimestampMs: Double = nextExpirationTimestampMs else { return nil } + guard let nextExpirationTimestampMs: Double = nextExpirationTimestampMs else { + SNLog("[DisappearingMessagesJob] No remaining expiring messages") + return nil + } /// The `expiresStartedAtMs` timestamp is now based on the `SnodeAPI.currentOffsetTimestampMs()` value /// so we need to make sure offset the `nextRunTimestamp` accordingly to ensure it runs at the correct local time let clockOffsetMs: Int64 = SnodeAPI.clockOffsetMs.wrappedValue + SNLog("[DisappearingMessagesJob] Scheduled future message expiration") return try? Job .filter(Job.Columns.variant == Job.Variant.disappearingMessages) .fetchOne(db)? @@ -104,7 +110,7 @@ public extension DisappearingMessagesJob { return updateNextRunIfNeeded(db, interactionIds: [interactionId], startedAtMs: startedAtMs) } catch { - SNLog("Failed to update the expiring messages timer on an interaction") + SNLog("[DisappearingMessagesJob] 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 index 7c1473f00..44b5e1921 100644 --- a/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift @@ -18,15 +18,16 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { deferred: @escaping (Job, Dependencies) -> (), dependencies: Dependencies = Dependencies() ) { + var changeCount: Int = -1 + // Update all 'sending' message states to 'failed' dependencies.storage.write { db in - let changeCount: Int = try Attachment + changeCount = 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") } + SNLog("[FailedAttachmentDownloadsJob] Marked \(changeCount) attachments as failed") success(job, false, dependencies) } } diff --git a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift index 4b4d5c4d1..9ec106631 100644 --- a/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import SignalCoreKit import SessionUtilitiesKit public enum FailedMessageSendsJob: JobExecutor { @@ -18,6 +17,9 @@ public enum FailedMessageSendsJob: JobExecutor { deferred: @escaping (Job, Dependencies) -> (), dependencies: Dependencies = Dependencies() ) { + var changeCount: Int = -1 + var attachmentChangeCount: Int = -1 + // Update all 'sending' message states to 'failed' dependencies.storage.write { db in let sendChangeCount: Int = try RecipientState @@ -26,14 +28,13 @@ public enum FailedMessageSendsJob: JobExecutor { let syncChangeCount: Int = try RecipientState .filter(RecipientState.Columns.state == RecipientState.State.syncing) .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failedToSync)) - let attachmentChangeCount: Int = try Attachment + attachmentChangeCount = try Attachment .filter(Attachment.Columns.state == Attachment.State.uploading) .updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failedUpload)) - let changeCount: Int = (sendChangeCount + syncChangeCount) - - SNLog("Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)") + changeCount = (sendChangeCount + syncChangeCount) } + SNLog("[FailedMessageSendsJob] Marked \(changeCount) message\(changeCount == 1 ? "" : "s") as failed (\(attachmentChangeCount) upload\(attachmentChangeCount == 1 ? "" : "s") cancelled)") success(job, false, dependencies) } } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 68e487e95..b35bf8621 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import PromiseKit import SignalCoreKit import SessionUtilitiesKit import SessionSnodeKit @@ -86,7 +85,7 @@ public enum GarbageCollectionJob: JobExecutor { SELECT \(interaction.alias[Column.rowID]) FROM \(Interaction.self) JOIN \(SessionThread.self) ON ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(thread[.id]) = \(interaction[.threadId]) ) JOIN ( @@ -118,6 +117,8 @@ public enum GarbageCollectionJob: JobExecutor { LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(job[.threadId]) LEFT JOIN \(Interaction.self) ON \(interaction[.id]) = \(job[.interactionId]) WHERE ( + -- Never delete config sync jobs, even if their threads were deleted + \(SQL("\(job[.variant]) != \(Job.Variant.configurationSync)")) AND ( \(job[.threadId]) IS NOT NULL AND \(thread[.id]) IS NULL @@ -296,6 +297,34 @@ public enum GarbageCollectionJob: JobExecutor { .filter(PendingReadReceipt.Columns.serverExpirationTimestamp <= timestampNow) .deleteAll(db) } + + if finalTypesToCollect.contains(.shadowThreads) { + // Shadow threads are thread records which were created to start a conversation that + // didn't actually get turned into conversations (ie. the app was closed or crashed + // before the user sent a message) + let thread: TypedTableAlias = TypedTableAlias() + let contact: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let closedGroup: TypedTableAlias = TypedTableAlias() + + try db.execute(literal: """ + DELETE FROM \(SessionThread.self) + WHERE \(Column.rowID) IN ( + SELECT \(thread.alias[Column.rowID]) + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) + WHERE ( + \(contact[.id]) IS NULL AND + \(openGroup[.threadId]) IS NULL AND + \(closedGroup[.threadId]) IS NULL AND + \(thread[.shouldBeVisible]) = false AND + \(SQL("\(thread[.id]) != \(getUserHexEncodedPublicKey(db))")) + ) + ) + """) + } }, completion: { _, _ in // Dispatch async so we can swap from the write queue to a read one (we are done writing) @@ -450,6 +479,7 @@ extension GarbageCollectionJob { case orphanedAttachmentFiles case orphanedProfileAvatars case expiredPendingReadReceipts + case shadowThreads } public struct Details: Codable { diff --git a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift index 386990d93..9eb36cccf 100644 --- a/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift @@ -1,8 +1,8 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SignalCoreKit import SessionUtilitiesKit import SessionSnodeKit @@ -23,103 +23,113 @@ public enum GroupLeavingJob: JobExecutor { guard let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData), + let threadId: String = job.threadId, let interactionId: Int64 = job.interactionId else { - failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) - return + SNLog("[GroupLeavingJob] Failed due to missing details") + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } - guard let thread: SessionThread = Storage.shared.read({ db in try? SessionThread.fetchOne(db, id: details.groupPublicKey)}) else { - SNLog("Can't leave nonexistent closed group.") - failure(job, MessageSenderError.noThread, true, dependencies) - return - } + let destination: Message.Destination = .closedGroup(groupPublicKey: threadId) - guard let closedGroup: ClosedGroup = Storage.shared.read({ db in try? thread.closedGroup.fetchOne(db)}) else { - failure(job, MessageSenderError.invalidClosedGroupUpdate, true, dependencies) - return - } - - Storage.shared.writeAsync { db -> Promise in - try MessageSender.sendNonDurably( - db, - message: ClosedGroupControlMessage( - kind: .memberLeft - ), - interactionId: interactionId, - in: thread - ) - } - .done(on: queue) { _ in - // Remove the group from the database and unsubscribe from PNs - ClosedGroupPoller.shared.stopPolling(for: details.groupPublicKey) - - Storage.shared.writeAsync { db in - let userPublicKey: String = getUserHexEncodedPublicKey(db) + dependencies.storage + .writePublisher { db in + guard (try? SessionThread.exists(db, id: threadId)) == true else { + SNLog("[GroupLeavingJob] Failed due to non-existent group conversation") + throw MessageSenderError.noThread + } + guard (try? ClosedGroup.exists(db, id: threadId)) == true else { + SNLog("[GroupLeavingJob] Failed due to non-existent group") + throw MessageSenderError.invalidClosedGroupUpdate + } - try closedGroup - .keyPairs - .deleteAll(db) - - let _ = PushNotificationAPI.performOperation( - .unsubscribe, - for: details.groupPublicKey, - publicKey: userPublicKey + return try MessageSender.preparedSendData( + db, + message: ClosedGroupControlMessage( + kind: .memberLeft + ), + to: destination, + namespace: destination.defaultNamespace, + interactionId: job.interactionId, + isSyncMessage: false ) - - try Interaction - .filter(id: interactionId) - .updateAll( - db, - [ - Interaction.Columns.variant.set(to: Interaction.Variant.infoClosedGroupCurrentUserLeft), - Interaction.Columns.body.set(to: "GROUP_YOU_LEFT".localized()) - ] - ) - - // 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) - } - - if details.deleteThread { - _ = try SessionThread - .filter(id: thread.id) - .deleteAll(db) - } } - success(job, false, dependencies) - } - .catch(on: queue) { error in - Storage.shared.writeAsync { db in - try Interaction - .filter(id: job.interactionId) - .updateAll( - db, - [ - Interaction.Columns.variant.set(to: Interaction.Variant.infoClosedGroupCurrentUserErrorLeaving), - Interaction.Columns.body.set(to: "group_unable_to_leave".localized()) + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: queue) + .receive(on: queue) + .sinkUntilComplete( + receiveCompletion: { result in + let failureChanges: [ConfigColumnAssignment] = [ + Interaction.Columns.variant + .set(to: Interaction.Variant.infoClosedGroupCurrentUserErrorLeaving), + Interaction.Columns.body.set(to: "group_unable_to_leave".localized()) + ] + let successfulChanges: [ConfigColumnAssignment] = [ + Interaction.Columns.variant + .set(to: Interaction.Variant.infoClosedGroupCurrentUserLeft), + Interaction.Columns.body.set(to: "GROUP_YOU_LEFT".localized()) + ] + + // Handle the appropriate response + dependencies.storage.writeAsync { db in + // If it failed due to one of these errors then clear out any associated data (as somehow + // the 'SessionThread' exists but not the data required to send the 'MEMBER_LEFT' message + // which would leave the user in a state where they can't leave the group) + let errorsToSucceed: [MessageSenderError] = [ + .invalidClosedGroupUpdate, + .noKeyPair ] - ) - } - success(job, false, dependencies) - } - .retainUntilComplete() - + let shouldSucceed: Bool = { + switch result { + case .failure(let error as MessageSenderError): return errorsToSucceed.contains(error) + case .failure: return false + default: return true + } + }() + + // Update the transaction + try Interaction + .filter(id: interactionId) + .updateAll( + db, + (shouldSucceed ? successfulChanges : failureChanges) + ) + + // If we succeed in leaving then we should try to clear the group data + guard shouldSucceed else { return } + + // Update the group (if the admin leaves the group is disbanded) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + let wasAdminUser: Bool = GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .filter(GroupMember.Columns.profileId == currentUserPublicKey) + .filter(GroupMember.Columns.role == GroupMember.Role.admin) + .isNotEmpty(db) + + if wasAdminUser { + try GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .deleteAll(db) + } + else { + try GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .filter(GroupMember.Columns.profileId == currentUserPublicKey) + .deleteAll(db) + } + + // Clear out the group info as needed + try ClosedGroup.removeKeysAndUnsubscribe( + db, + threadId: threadId, + removeGroupData: details.deleteThread, + calledFromConfigHandling: false + ) + } + + success(job, false, dependencies) + } + ) } } @@ -128,20 +138,14 @@ public enum GroupLeavingJob: JobExecutor { extension GroupLeavingJob { public struct Details: Codable { private enum CodingKeys: String, CodingKey { - case groupPublicKey case deleteThread } - public let groupPublicKey: String public let deleteThread: Bool // MARK: - Initialization - public init( - groupPublicKey: String, - deleteThread: Bool - ) { - self.groupPublicKey = groupPublicKey + public init(deleteThread: Bool) { self.deleteThread = deleteThread } @@ -151,15 +155,13 @@ extension GroupLeavingJob { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) self = Details( - groupPublicKey: try container.decode(String.self, forKey: .groupPublicKey), deleteThread: try container.decode(Bool.self, forKey: .deleteThread) ) } public func encode(to encoder: Encoder) throws { var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(groupPublicKey, forKey: .groupPublicKey) + try container.encode(deleteThread, forKey: .deleteThread) } } diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 2ea8fb90a..696442bd8 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import PromiseKit import SessionUtilitiesKit public enum MessageReceiveJob: JobExecutor { @@ -19,27 +18,49 @@ public enum MessageReceiveJob: JobExecutor { dependencies: Dependencies = Dependencies() ) { 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, true, dependencies) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) + } + + // Ensure no config messages are sent through this job + guard !details.messages.contains(where: { $0.variant == .sharedConfigMessage }) else { + SNLog("[MessageReceiveJob] Config messages incorrectly sent to the 'messageReceive' job") + return failure(job, MessageReceiverError.invalidSharedConfigMessageHandling, true, dependencies) } var updatedJob: Job = job - var leastSevereError: Error? + var lastError: Error? + var remainingMessagesToProcess: [Details.MessageInfo] = [] + let messageData: [(info: Details.MessageInfo, proto: SNProtoContent)] = details.messages + .filter { $0.variant != .sharedConfigMessage } + .compactMap { messageInfo -> (info: Details.MessageInfo, proto: SNProtoContent)? in + do { + return (messageInfo, try SNProtoContent.parseData(messageInfo.serializedProtoData)) + } + catch { + SNLog("Couldn't receive message due to error: \(error)") + lastError = 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) + return nil + } + } dependencies.storage.write { db in - var remainingMessagesToProcess: [Details.MessageInfo] = [] - - for messageInfo in details.messages { + for (messageInfo, protoContent) in messageData { do { try MessageReceiver.handle( db, + threadId: threadId, + threadVariant: messageInfo.threadVariant, message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - openGroupId: nil + associatedWithProto: protoContent ) } catch { @@ -62,7 +83,7 @@ public enum MessageReceiveJob: JobExecutor { default: SNLog("Couldn't receive message due to error: \(error)") - leastSevereError = error + lastError = error // We failed to process this message but it is a retryable error // so add it to the list to re-process @@ -73,6 +94,8 @@ public enum MessageReceiveJob: JobExecutor { // If any messages failed to process then we want to update the job to only include // those failed messages + guard !remainingMessagesToProcess.isEmpty else { return } + updatedJob = try job .with( details: Details( @@ -85,7 +108,7 @@ public enum MessageReceiveJob: JobExecutor { } // Handle the result - switch leastSevereError { + switch lastError { case let error as MessageReceiverError where !error.isRetryable: failure(updatedJob, error, true, dependencies) @@ -102,27 +125,33 @@ public enum MessageReceiveJob: JobExecutor { extension MessageReceiveJob { public struct Details: Codable { + typealias SharedConfigInfo = (message: SharedConfigMessage, serializedProtoData: Data) + public struct MessageInfo: Codable { private enum CodingKeys: String, CodingKey { case message case variant + case threadVariant case serverExpirationTimestamp case serializedProtoData } public let message: Message public let variant: Message.Variant + public let threadVariant: SessionThread.Variant public let serverExpirationTimestamp: TimeInterval? public let serializedProtoData: Data public init( message: Message, variant: Message.Variant, + threadVariant: SessionThread.Variant, serverExpirationTimestamp: TimeInterval?, proto: SNProtoContent ) throws { self.message = message self.variant = variant + self.threadVariant = threadVariant self.serverExpirationTimestamp = serverExpirationTimestamp self.serializedProtoData = try proto.serializedData() } @@ -130,11 +159,13 @@ extension MessageReceiveJob { private init( message: Message, variant: Message.Variant, + threadVariant: SessionThread.Variant, serverExpirationTimestamp: TimeInterval?, serializedProtoData: Data ) { self.message = message self.variant = variant + self.threadVariant = threadVariant self.serverExpirationTimestamp = serverExpirationTimestamp self.serializedProtoData = serializedProtoData } @@ -152,6 +183,24 @@ extension MessageReceiveJob { self = MessageInfo( message: try variant.decode(from: container, forKey: .message), variant: variant, + threadVariant: (try? container.decode(SessionThread.Variant.self, forKey: .threadVariant)) + .defaulting(to: { + /// We used to store a 'groupPublicKey' value within the 'Message' type which was used to + /// determine the thread variant, now we just encode the variant directly but there may be + /// some legacy jobs which still have `groupPublicKey` so we have this mechanism + /// + /// **Note:** This can probably be removed a couple of releases after the user config + /// update release (ie. after June 2023) + class LegacyGroupPubkey: Codable { + let groupPublicKey: String? + } + + if (try? container.decode(LegacyGroupPubkey.self, forKey: .message))?.groupPublicKey != nil { + return .legacyGroup + } + + return .contact + }()), serverExpirationTimestamp: try? container.decode(TimeInterval.self, forKey: .serverExpirationTimestamp), serializedProtoData: try container.decode(Data.self, forKey: .serializedProtoData) ) @@ -167,6 +216,7 @@ extension MessageReceiveJob { try container.encode(message, forKey: .message) try container.encode(variant, forKey: .variant) + try container.encode(threadVariant, forKey: .threadVariant) try container.encodeIfPresent(serverExpirationTimestamp, forKey: .serverExpirationTimestamp) try container.encode(serializedProtoData, forKey: .serializedProtoData) } diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index b36cb6645..3ff522544 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -1,8 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SignalCoreKit import SessionUtilitiesKit import SessionSnodeKit @@ -24,189 +24,196 @@ public enum MessageSendJob: JobExecutor { let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) - return + SNLog("[MessageSendJob] Failing due to missing details") + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } // 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 { + /// Ensure any associated attachments have already been uploaded before sending the message + /// + /// **Note:** Reactions reference their original message so we need to ignore this logic for reaction messages to ensure we don't + /// incorrectly re-upload incoming attachments that the user reacted to, we also want to exclude "sync" messages since they should + /// already have attachments in a valid state + if + details.message is VisibleMessage, + (details.message as? VisibleMessage)?.reaction == nil + { guard let jobId: Int64 = job.id, let interactionId: Int64 = job.interactionId else { - failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) - return + SNLog("[MessageSendJob] Failing due to missing details") + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } - // 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 dependencies.storage.read({ db in try Interaction.exists(db, id: interactionId) }) == true else { - failure(job, StorageError.objectNotFound, true, dependencies) - 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])? = dependencies.storage.write { db in - let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment - .stateInfo(interactionId: interactionId) - .fetchAll(db) - let maybeFileIds: [String?] = allAttachmentStateInfo - .sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex } - .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 + // Retrieve the current attachment state + typealias AttachmentState = (error: Error?, pendingUploadAttachmentIds: [String], preparedFileIds: [String]) + + let attachmentState: AttachmentState = dependencies.storage + .read { db in + // 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 try Interaction.exists(db, id: interactionId) else { + SNLog("[MessageSendJob] Failing due to missing interaction") + return (StorageError.objectNotFound, [], []) + } + + // Get the current state of the attachments + let allAttachmentStateInfo: [Attachment.StateInfo] = try Attachment + .stateInfo(interactionId: interactionId) + .fetchAll(db) + let maybeFileIds: [String?] = allAttachmentStateInfo + .sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex } + .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 { + SNLog("[MessageSendJob] Failing due to failed attachment upload") + return (AttachmentError.notUploaded, [], fileIds) + } + + /// Find all attachmentIds for attachments which need to be uploaded + /// + /// **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) + let pendingUploadAttachmentIds: [String] = 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 - !dependencies.jobRunner.hasJob( - of: .attachmentUpload, - with: AttachmentUploadJob.Details( - messageSendJobId: jobId, - attachmentId: stateInfo.attachmentId - ) - ) - } - .compactMap { stateInfo -> (jobId: Int64, job: Job)? in - dependencies.jobRunner - .insert( - db, - job: Job( - variant: .attachmentUpload, - behaviour: .runOnce, - threadId: job.threadId, - interactionId: interactionId, - details: AttachmentUploadJob.Details( - messageSendJobId: jobId, - attachmentId: stateInfo.attachmentId - ) - ), - before: job, - dependencies: dependencies - ) - } - .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), // shouldFail - hasPendingUploads, // shouldDefer - 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, dependencies) - return + .map { $0.attachmentId } + + return (nil, pendingUploadAttachmentIds, fileIds) + } + .defaulting(to: (MessageSenderError.invalidMessage, [], [])) + + /// If we got an error when trying to retrieve the attachment state then this job is actually invalid so it + /// should permanently fail + guard attachmentState.error == nil else { + return failure(job, (attachmentState.error ?? MessageSenderError.invalidMessage), true, dependencies) } - // Defer the job if we found incomplete uploads - guard attachmentState?.shouldDefer == false else { - deferred(job, dependencies) - return + /// If we have any pending (or failed) attachment uploads then we should create jobs for them and insert them into the + /// queue before the current job and defer it (this will mean the current job will re-run after these inserted jobs complete) + guard attachmentState.pendingUploadAttachmentIds.isEmpty else { + dependencies.storage.write { db in + try attachmentState.pendingUploadAttachmentIds + .filter { attachmentId in + // Don't add a new job if there is one already in the queue + !dependencies.jobRunner.hasJob( + of: .attachmentUpload, + with: AttachmentUploadJob.Details( + messageSendJobId: jobId, + attachmentId: attachmentId + ) + ) + } + .compactMap { attachmentId -> (jobId: Int64, job: Job)? in + JobRunner + .insert( + db, + job: Job( + variant: .attachmentUpload, + behaviour: .runOnce, + threadId: job.threadId, + interactionId: interactionId, + details: AttachmentUploadJob.Details( + messageSendJobId: jobId, + attachmentId: attachmentId + ) + ), + before: job + ) + } + .forEach { otherJobId, _ in + // Create the dependency between the jobs + try JobDependencies( + jobId: jobId, + dependantId: otherJobId + ) + .insert(db) + } + } + + SNLog("[MessageSendJob] Deferring due to pending attachment uploads") + return deferred(job, dependencies) } - + // Store the fileIds so they can be sent with the open group message content - messageFileIds = (attachmentState?.fileIds ?? []) + messageFileIds = attachmentState.preparedFileIds } // 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 - dependencies.storage.writeAsync { db -> Promise in - try MessageSender.sendImmediate( - db, - message: details.message, - to: details.destination - .with(fileIds: messageFileIds), - interactionId: job.interactionId, - isSyncMessage: (details.isSyncMessage == true) - ) - } - .done(on: queue) { _ in success(job, false, dependencies) } - .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, dependencies) - - case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited - failure(job, error, true, dependencies) - - 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), dependencies) - - default: - SNLog("Failed to send \(type(of: details.message)).") - - if details.message is VisibleMessage { - guard - let interactionId: Int64 = job.interactionId, - dependencies.storage.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, dependencies) - return - } - } - - failure(job, error, false, dependencies) + /// Perform the actual message sending + /// + /// **Note:** No need to upload attachments as part of this process as the above logic splits that out into it's own job + /// so we shouldn't get here until attachments have already been uploaded + dependencies.storage + .writePublisher { db in + try MessageSender.preparedSendData( + db, + message: details.message, + to: details.destination, + namespace: details.destination.defaultNamespace, + interactionId: job.interactionId, + isSyncMessage: details.isSyncMessage + ) } - } - .retainUntilComplete() + .map { sendData in sendData.with(fileIds: messageFileIds) } + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: queue) + .receive(on: queue) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: success(job, false, dependencies) + case .failure(let error): + SNLog("[MessageSendJob] Couldn't send message due to error: \(error).") + + switch error { + case let senderError as MessageSenderError where !senderError.isRetryable: + failure(job, error, true, dependencies) + + case OnionRequestAPIError.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited + failure(job, error, true, dependencies) + + case SnodeAPIError.clockOutOfSync: + SNLog("[MessageSendJob] \(originalSentTimestamp != nil ? "Permanently Failing" : "Failing") to send \(type(of: details.message)) due to clock out of sync issue.") + failure(job, error, (originalSentTimestamp != nil), dependencies) + + default: + SNLog("[MessageSendJob] Failed to send \(type(of: details.message)).") + + if details.message is VisibleMessage { + guard + let interactionId: Int64 = job.interactionId, + dependencies.storage.read({ db in try Interaction.exists(db, id: interactionId) }) == true + else { + // The message has been deleted so permanently fail the job + return failure(job, error, true, dependencies) + } + } + + failure(job, error, false, dependencies) + } + } + } + ) } } @@ -223,7 +230,7 @@ extension MessageSendJob { public let destination: Message.Destination public let message: Message - public let isSyncMessage: Bool? + public let isSyncMessage: Bool public let variant: Message.Variant? // MARK: - Initialization @@ -231,7 +238,7 @@ extension MessageSendJob { public init( destination: Message.Destination, message: Message, - isSyncMessage: Bool? = nil + isSyncMessage: Bool = false ) { self.destination = destination self.message = message @@ -252,7 +259,7 @@ extension MessageSendJob { self = Details( destination: try container.decode(Message.Destination.self, forKey: .destination), message: try variant.decode(from: container, forKey: .message), - isSyncMessage: try? container.decode(Bool.self, forKey: .isSyncMessage) + isSyncMessage: ((try? container.decode(Bool.self, forKey: .isSyncMessage)) ?? false) ) } @@ -266,7 +273,7 @@ extension MessageSendJob { try container.encode(destination, forKey: .destination) try container.encode(message, forKey: .message) - try container.encodeIfPresent(isSyncMessage, forKey: .isSyncMessage) + try container.encode(isSyncMessage, forKey: .isSyncMessage) try container.encode(variant, forKey: .variant) } } diff --git a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift index cf30ea3c1..a128f459d 100644 --- a/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift +++ b/SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift @@ -1,7 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit +import Combine import SessionSnodeKit import SessionUtilitiesKit @@ -22,20 +22,26 @@ public enum NotifyPushServerJob: JobExecutor { let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) - return + SNLog("[NotifyPushServerJob] Failing due to missing details") + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } PushNotificationAPI .notify( recipient: details.message.recipient, with: details.message.data, - maxRetryCount: 4, - queue: queue + maxRetryCount: 4 + ) + .subscribe(on: queue) + .receive(on: queue) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: success(job, false, dependencies) + case .failure(let error): failure(job, error, false, dependencies) + } + } ) - .done(on: queue) { _ in success(job, false, dependencies) } - .catch(on: queue) { error in failure(job, error, false, dependencies) } - .retainUntilComplete() } } diff --git a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift index 87c09ae50..d16e8ce9b 100644 --- a/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift +++ b/SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import SignalCoreKit import SessionUtilitiesKit public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { @@ -44,8 +43,20 @@ public enum RetrieveDefaultOpenGroupRoomsJob: JobExecutor { } OpenGroupManager.getDefaultRoomsIfNeeded() - .done(on: queue) { _ in success(job, false, dependencies) } - .catch(on: queue) { error in failure(job, error, false, dependencies) } - .retainUntilComplete() + .subscribe(on: queue) + .receive(on: queue) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: + SNLog("[RetrieveDefaultOpenGroupRoomsJob] Successfully retrieved default Community rooms") + success(job, false, dependencies) + + case .failure(let error): + SNLog("[RetrieveDefaultOpenGroupRoomsJob] Failed to get default Community rooms") + failure(job, error, false, dependencies) + } + } + ) } } diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index 62f9ff825..77097df92 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -1,15 +1,15 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine 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 + private static let maxRunFrequency: TimeInterval = 3 public static func run( _ job: Job, @@ -24,66 +24,72 @@ public enum SendReadReceiptsJob: JobExecutor { let detailsData: Data = job.details, let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData) else { - failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) - return + return failure(job, JobRunnerError.missingRequiredDetails, true, dependencies) } // 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, dependencies) - return + return success(job, true, dependencies) } dependencies.storage - .writeAsync { db in - try MessageSender.sendImmediate( + .writePublisher { db in + try MessageSender.preparedSendData( db, message: ReadReceipt( timestamps: details.timestampMsValues.map { UInt64($0) } ), to: details.destination, + namespace: details.destination.defaultNamespace, interactionId: nil, isSyncMessage: false ) } - .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? = dependencies.storage.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 + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: queue) + .receive(on: queue) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .failure(let error): failure(job, error, false, dependencies) + case .finished: + // When we complete the 'SendReadReceiptsJob' we want to immediately schedule + // another one for the same thread but with a 'nextRunTimestamp' set to the + // 'maxRunFrequency' value to throttle the read receipt requests + var shouldFinishCurrentJob: Bool = false + let nextRunTimestamp: TimeInterval = (Date().timeIntervalSince1970 + maxRunFrequency) + + 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, dependencies) } - - return try job - .with(details: Details(destination: details.destination, timestampMsValues: [])) - .defaulting(to: job) - .with(nextRunTimestamp: nextRunTimestamp) - .saved(db) } - - success(updatedJob ?? job, shouldFinishCurrentJob, dependencies) - } - .catch(on: queue) { error in failure(job, error, false, dependencies) } - .retainUntilComplete() + ) } } diff --git a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift index ee7d69125..c29ff6200 100644 --- a/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift +++ b/SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import SignalCoreKit import SessionUtilitiesKit public enum UpdateProfilePictureJob: JobExecutor { @@ -20,8 +19,7 @@ public enum UpdateProfilePictureJob: JobExecutor { ) { // Don't run when inactive or not in main app guard (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) else { - deferred(job, dependencies) // Don't need to do anything if it's not the main app - return + return deferred(job, dependencies) // Don't need to do anything if it's not the main app } // Only re-upload the profile picture if enough time has passed since the last upload @@ -38,31 +36,33 @@ public enum UpdateProfilePictureJob: JobExecutor { .updateAll(db, Job.Columns.nextRunTimestamp.set(to: 0)) } } - deferred(job, dependencies) - return + + SNLog("[UpdateProfilePictureJob] Deferred as not enough time has passed since the last update") + return deferred(job, dependencies) } // Note: The user defaults flag is updated in ProfileManager let profile: Profile = Profile.fetchOrCreateCurrentUser(dependencies: dependencies) - let profileFilePath: String? = profile.profilePictureFileName - .map { ProfileManager.profileAvatarFilepath(filename: $0) } + let profilePictureData: Data? = profile.profilePictureFileName + .map { ProfileManager.loadProfileData(with: $0) } ProfileManager.updateLocal( queue: queue, profileName: profile.name, - image: nil, - imageFilePath: profileFilePath, - success: { db, _ in - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - + avatarUpdate: (profilePictureData.map { .uploadImageData($0) } ?? .none), + success: { db 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 { + SNLog("[UpdateProfilePictureJob] Profile successfully updated") success(job, false, dependencies) } }, - failure: { error in failure(job, error, false, dependencies) } + failure: { error in + SNLog("[UpdateProfilePictureJob] Failed to update profile") + failure(job, error, false, dependencies) + } ) } } diff --git a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift index 533f771ba..4fcfae9c4 100644 --- a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift @@ -2,7 +2,6 @@ import Foundation import GRDB -import WebRTC import SessionUtilitiesKit /// See https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription for more information. diff --git a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift index 9f2214fe9..f9017965b 100644 --- a/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ClosedGroupControlMessage.swift @@ -3,7 +3,6 @@ import Foundation import GRDB import Sodium -import Curve25519Kit import SessionUtilitiesKit public final class ClosedGroupControlMessage: ControlMessage { @@ -37,7 +36,7 @@ public final class ClosedGroupControlMessage: ControlMessage { case wrappers } - case new(publicKey: Data, name: String, encryptionKeyPair: Box.KeyPair, members: [Data], admins: [Data], expirationTimer: UInt32) + case new(publicKey: Data, name: String, encryptionKeyPair: KeyPair, members: [Data], admins: [Data], expirationTimer: UInt32) /// An encryption key pair encrypted for each member individually. /// @@ -69,7 +68,7 @@ public final class ClosedGroupControlMessage: ControlMessage { let newDescription: String = Kind.new( publicKey: Data(), name: "", - encryptionKeyPair: Box.KeyPair(publicKey: [], secretKey: []), + encryptionKeyPair: KeyPair(publicKey: [], secretKey: []), members: [], admins: [], expirationTimer: 0 @@ -80,7 +79,7 @@ public final class ClosedGroupControlMessage: ControlMessage { self = .new( publicKey: try container.decode(Data.self, forKey: .publicKey), name: try container.decode(String.self, forKey: .name), - encryptionKeyPair: Box.KeyPair( + encryptionKeyPair: KeyPair( publicKey: try container.decode([UInt8].self, forKey: .encryptionPublicKey), secretKey: try container.decode([UInt8].self, forKey: .encryptionSecretKey) ), @@ -253,7 +252,7 @@ public final class ClosedGroupControlMessage: ControlMessage { kind: .new( publicKey: publicKey, name: name, - encryptionKeyPair: Box.KeyPair( + encryptionKeyPair: KeyPair( publicKey: encryptionKeyPairAsProto.publicKey.removingIdPrefixIfNeeded().bytes, secretKey: encryptionKeyPairAsProto.privateKey.bytes ), @@ -339,8 +338,6 @@ public final class ClosedGroupControlMessage: ControlMessage { let contentProto = SNProtoContent.builder() let dataMessageProto = SNProtoDataMessage.builder() dataMessageProto.setClosedGroupControlMessage(try closedGroupControlMessage.build()) - // Group context - try setGroupContextIfNeeded(db, on: dataMessageProto) contentProto.setDataMessage(try dataMessageProto.build()) return try contentProto.build() } catch { @@ -376,7 +373,7 @@ public extension ClosedGroupControlMessage.Kind { let addedMemberNames: [String] = memberIds .map { knownMemberNameMap[$0] ?? - Profile.truncated(id: $0, threadVariant: .closedGroup) + Profile.truncated(id: $0, threadVariant: .legacyGroup) } return String( @@ -399,7 +396,7 @@ public extension ClosedGroupControlMessage.Kind { let removedMemberNames: [String] = memberIds.removing(userPublicKey) .map { knownMemberNameMap[$0] ?? - Profile.truncated(id: $0, threadVariant: .closedGroup) + Profile.truncated(id: $0, threadVariant: .legacyGroup) } let format: String = (removedMemberNames.count > 1 ? "GROUP_MEMBERS_REMOVED".localized() : diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift index 72289c200..6c8c5977a 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage+Convenience.swift @@ -9,7 +9,7 @@ extension ConfigurationMessage { let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser(db) let displayName: String = currentUserProfile.name let profilePictureUrl: String? = currentUserProfile.profilePictureUrl - let profileKey: Data? = currentUserProfile.profileEncryptionKey?.keyData + let profileKey: Data? = currentUserProfile.profileEncryptionKey let closedGroups: Set = try ClosedGroup.fetchAll(db) .compactMap { closedGroup -> CMClosedGroup? in guard let latestKeyPair: ClosedGroupKeyPair = try closedGroup.fetchLatestKeyPair(db) else { @@ -42,8 +42,8 @@ extension ConfigurationMessage { .filter(OpenGroup.Columns.roomToken != "") .filter(OpenGroup.Columns.isActive) .fetchAll(db) - .map { openGroup in - OpenGroup.urlFor( + .compactMap { openGroup in + SessionUtil.communityUrlFor( server: openGroup.server, roomToken: openGroup.roomToken, publicKey: openGroup.publicKey @@ -62,7 +62,7 @@ extension ConfigurationMessage { publicKey: contact.id, displayName: (profile?.name ?? contact.id), profilePictureUrl: profile?.profilePictureUrl, - profileKey: profile?.profileEncryptionKey?.keyData, + profileKey: profile?.profileEncryptionKey, hasIsApproved: true, isApproved: contact.isApproved, hasIsBlocked: true, diff --git a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift index 9ffa5e6ce..44977dd52 100644 --- a/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/ConfigurationMessage.swift @@ -1,9 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import Sodium import GRDB -import Curve25519Kit import SessionUtilitiesKit public final class ConfigurationMessage: ControlMessage { diff --git a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift index 3d7aadda5..1d8de57df 100644 --- a/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift +++ b/SessionMessagingKit/Messages/Control Messages/ExpirationTimerUpdate.swift @@ -77,13 +77,6 @@ public final class ExpirationTimerUpdate: ControlMessage { dataMessageProto.setFlags(UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue)) dataMessageProto.setExpireTimer(duration) if let syncTarget = syncTarget { dataMessageProto.setSyncTarget(syncTarget) } - // Group context - do { - try setGroupContextIfNeeded(db, on: dataMessageProto) - } catch { - SNLog("Couldn't construct expiration timer update proto from: \(self).") - return nil - } let contentProto = SNProtoContent.builder() do { contentProto.setDataMessage(try dataMessageProto.build()) diff --git a/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift b/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift new file mode 100644 index 000000000..09aecc1c5 --- /dev/null +++ b/SessionMessagingKit/Messages/Control Messages/SharedConfigMessage.swift @@ -0,0 +1,144 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public final class SharedConfigMessage: ControlMessage { + private enum CodingKeys: String, CodingKey { + case kind + case seqNo + case data + } + + public var kind: Kind + public var seqNo: Int64 + public var data: Data + + /// SharedConfigMessages should last for 30 days rather than the standard 14 + public override var ttl: UInt64 { 30 * 24 * 60 * 60 * 1000 } + public override var isSelfSendValid: Bool { true } + + // MARK: - Kind + + public enum Kind: CustomStringConvertible, Codable { + case userProfile + case contacts + case convoInfoVolatile + case userGroups + + public var description: String { + switch self { + case .userProfile: return "userProfile" + case .contacts: return "contacts" + case .convoInfoVolatile: return "convoInfoVolatile" + case .userGroups: return "userGroups" + } + } + } + + // MARK: - Initialization + + public init( + kind: Kind, + seqNo: Int64, + data: Data, + sentTimestamp: UInt64? = nil + ) { + self.kind = kind + self.seqNo = seqNo + self.data = data + + super.init(sentTimestamp: sentTimestamp) + } + + // MARK: - Codable + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + kind = try container.decode(Kind.self, forKey: .kind) + seqNo = try container.decode(Int64.self, forKey: .seqNo) + data = try container.decode(Data.self, forKey: .data) + + 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.encode(kind, forKey: .kind) + try container.encode(seqNo, forKey: .seqNo) + try container.encode(data, forKey: .data) + } + + // MARK: - Proto Conversion + + public override class func fromProto(_ proto: SNProtoContent, sender: String) -> SharedConfigMessage? { + guard let sharedConfigMessage = proto.sharedConfigMessage else { return nil } + + return SharedConfigMessage( + kind: { + switch sharedConfigMessage.kind { + case .userProfile: return .userProfile + case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .userGroups: return .userGroups + } + }(), + seqNo: sharedConfigMessage.seqno, + data: sharedConfigMessage.data + ) + } + + public override func toProto(_ db: Database) -> SNProtoContent? { + do { + let sharedConfigMessage: SNProtoSharedConfigMessage.SNProtoSharedConfigMessageBuilder = SNProtoSharedConfigMessage.builder( + kind: { + switch self.kind { + case .userProfile: return .userProfile + case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .userGroups: return .userGroups + } + }(), + seqno: self.seqNo, + data: self.data + ) + + let contentProto = SNProtoContent.builder() + contentProto.setSharedConfigMessage(try sharedConfigMessage.build()) + return try contentProto.build() + } catch { + SNLog("Couldn't construct data extraction notification proto from: \(self).") + return nil + } + } + + // MARK: - Description + + public var description: String { + """ + SharedConfigMessage( + kind: \(kind.description), + seqNo: \(seqNo), + data: \(data.count) bytes + ) + """ + } +} + +// MARK: - Convenience + +public extension SharedConfigMessage.Kind { + var configDumpVariant: ConfigDump.Variant { + switch self { + case .userProfile: return .userProfile + case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .userGroups: return .userGroups + } + } +} diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index e1eaad9bc..6dbc8aeec 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -2,10 +2,11 @@ import Foundation import GRDB +import SessionSnodeKit import SessionUtilitiesKit public extension Message { - enum Destination: Codable { + enum Destination: Codable, Hashable { case contact(publicKey: String) case closedGroup(groupPublicKey: String) case openGroup( @@ -16,33 +17,44 @@ public extension Message { fileIds: [String]? = nil ) case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) - - static func from( + + public var defaultNamespace: SnodeAPI.Namespace? { + switch self { + case .contact: return .`default` + case .closedGroup: return .legacyClosedGroup + default: return nil + } + } + + public static func from( _ db: Database, - thread: SessionThread, + threadId: String, + threadVariant: SessionThread.Variant, fileIds: [String]? = nil ) throws -> Message.Destination { - switch thread.variant { + switch threadVariant { case .contact: - if SessionId.Prefix(from: thread.id) == .blinded { - guard let lookup: BlindedIdLookup = try? BlindedIdLookup.fetchOne(db, id: thread.id) else { + let prefix: SessionId.Prefix? = SessionId.Prefix(from: threadId) + + if prefix == .blinded15 || prefix == .blinded25 { + guard let lookup: BlindedIdLookup = try? BlindedIdLookup.fetchOne(db, id: threadId) 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 + blindedPublicKey: threadId ) } - return .contact(publicKey: thread.id) + return .contact(publicKey: threadId) - case .closedGroup: - return .closedGroup(groupPublicKey: thread.id) + case .legacyGroup, .group: + return .closedGroup(groupPublicKey: threadId) - case .openGroup: - guard let openGroup: OpenGroup = try thread.openGroup.fetchOne(db) else { + case .community: + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { throw StorageError.objectNotFound } diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index d66d85c4f..7de4f560e 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -8,12 +8,10 @@ import SessionUtilitiesKit /// Abstract base class for `VisibleMessage` and `ControlMessage`. public class Message: Codable { public var id: 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 serverHash: String? @@ -34,7 +32,6 @@ public class Message: Codable { public init( id: String? = nil, - threadId: String? = nil, sentTimestamp: UInt64? = nil, receivedTimestamp: UInt64? = nil, recipient: String? = nil, @@ -44,12 +41,10 @@ public class Message: Codable { 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 } @@ -63,31 +58,18 @@ public class Message: Codable { public func toProto(_ db: Database) -> SNProtoContent? { preconditionFailure("toProto(_:) is abstract and must be overridden.") } - - 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: legacyGroupId, type: .deliver) - dataMessage.setGroup(try groupProto.build()) - } } // MARK: - Message Parsing/Processing public typealias ProcessedMessage = ( - threadId: String?, + threadId: String, + threadVariant: SessionThread.Variant, proto: SNProtoContent, messageInfo: MessageReceiveJob.Details.MessageInfo ) public extension Message { - static let nonThreadMessageId: String = "NON_THREAD_MESSAGE" - enum Variant: String, Codable { case readReceipt case typingIndicator @@ -99,6 +81,7 @@ public extension Message { case messageRequestResponse case visibleMessage case callMessage + case sharedConfigMessage init?(from type: Message) { switch type { @@ -112,6 +95,7 @@ public extension Message { case is MessageRequestResponse: self = .messageRequestResponse case is VisibleMessage: self = .visibleMessage case is CallMessage: self = .callMessage + case is SharedConfigMessage: self = .sharedConfigMessage default: return nil } } @@ -128,6 +112,7 @@ public extension Message { case .messageRequestResponse: return MessageRequestResponse.self case .visibleMessage: return VisibleMessage.self case .callMessage: return CallMessage.self + case .sharedConfigMessage: return SharedConfigMessage.self } } @@ -148,6 +133,7 @@ public extension Message { 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) + case .sharedConfigMessage: return try container.decode(SharedConfigMessage.self, forKey: key) } } } @@ -165,7 +151,8 @@ public extension Message { .unsendRequest, .messageRequestResponse, .visibleMessage, - .callMessage + .callMessage, + .sharedConfigMessage ] return prioritisedVariants @@ -176,6 +163,26 @@ public extension Message { } } + static func requiresExistingConversation(message: Message, threadVariant: SessionThread.Variant) -> Bool { + switch threadVariant { + case .contact, .community: return false + + case .legacyGroup: + switch message { + case let controlMessage as ClosedGroupControlMessage: + switch controlMessage.kind { + case .new: return false + default: return true + } + + default: return true + } + + case .group: + return false + } + } + static func shouldSync(message: Message) -> Bool { switch message { case is VisibleMessage: return true @@ -199,6 +206,28 @@ public extension Message { } } + static func threadId(forMessage message: Message, destination: Message.Destination) -> String { + switch destination { + case .contact(let publicKey): + // 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 + } + + return (maybeSyncTarget ?? 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 + } + } + static func processRawReceivedMessage( _ db: Database, rawMessage: SnodeReceivedMessage @@ -216,6 +245,19 @@ public extension Message { handleClosedGroupKeyUpdateMessages: true ) + // Ensure we actually want to de-dupe messages for this namespace, otherwise just + // succeed early + guard rawMessage.namespace.shouldDedupeMessages else { + // If we want to track the last hash then upsert the raw message info (don't + // want to fail if it already exsits because we don't want to dedupe messages + // in this namespace) + if rawMessage.namespace.shouldFetchSinceLastHash { + _ = try rawMessage.info.saved(db) + } + + return processedMessage + } + // Retrieve the number of entries we have for the hash of this message let numExistingHashes: Int = (try? SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.hash == rawMessage.info.hash) @@ -235,15 +277,9 @@ public extension Message { 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 + // For some error cases we want to update the last hash so do so + if (error as? MessageReceiverError)?.shouldUpdateLastHash == true { + _ = try? rawMessage.info.inserted(db) } throw error @@ -368,12 +404,21 @@ public extension Message { var results: [Reaction] = [] guard let reactions = message.reactions else { return results } let userPublicKey: String = getUserHexEncodedPublicKey(db) - let blindedUserPublicKey: String? = SessionThread + let blinded15UserPublicKey: String? = SessionThread .getUserHexEncodedBlindedKey( db, threadId: openGroupId, - threadVariant: .openGroup + threadVariant: .community, + blindingPrefix: .blinded15 ) + let blinded25UserPublicKey: String? = SessionThread + .getUserHexEncodedBlindedKey( + db, + threadId: openGroupId, + threadVariant: .community, + blindingPrefix: .blinded25 + ) + for (encodedEmoji, rawReaction) in reactions { if let decodedEmoji = encodedEmoji.removingPercentEncoding, rawReaction.count > 0, @@ -420,7 +465,11 @@ public extension Message { let timestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs() let maxLength: Int = shouldAddSelfReaction ? 4 : 5 let desiredReactorIds: [String] = reactors - .filter { $0 != blindedUserPublicKey && $0 != userPublicKey } // Remove current user for now, will add back if needed + .filter { id -> Bool in + id != blinded15UserPublicKey && + id != blinded25UserPublicKey && + id != userPublicKey + } // Remove current user for now, will add back if needed .prefix(maxLength) .map{ $0 } @@ -489,7 +538,7 @@ public extension Message { handleClosedGroupKeyUpdateMessages: Bool, dependencies: SMKDependencies = SMKDependencies() ) throws -> ProcessedMessage? { - let (message, proto, threadId) = try MessageReceiver.parse( + let (message, proto, threadId, threadVariant) = try MessageReceiver.parse( db, envelope: envelope, serverExpirationTimestamp: serverExpirationTimestamp, @@ -515,7 +564,12 @@ public extension Message { case let closedGroupControlMessage as ClosedGroupControlMessage: switch closedGroupControlMessage.kind { case .encryptionKeyPair: - try MessageReceiver.handleClosedGroupControlMessage(db, closedGroupControlMessage) + try MessageReceiver.handleClosedGroupControlMessage( + db, + threadId: threadId, + threadVariant: threadVariant, + message: closedGroupControlMessage + ) return nil default: break @@ -544,10 +598,12 @@ public extension Message { return ( threadId, + threadVariant, proto, try MessageReceiveJob.Details.MessageInfo( message: message, variant: variant, + threadVariant: threadVariant, serverExpirationTimestamp: serverExpirationTimestamp, proto: proto ) diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift index dcec57fc5..8f63ed5a9 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Profile.swift @@ -114,7 +114,7 @@ public extension VisibleMessage { extension VisibleMessage.VMProfile { init(profile: Profile) { self.displayName = profile.name - self.profileKey = profile.profileEncryptionKey?.keyData + self.profileKey = profile.profileEncryptionKey self.profilePictureUrl = profile.profilePictureUrl } } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 7132723b9..eed2fbe5d 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -168,12 +168,6 @@ public final class VisibleMessage: Message { let attachments: [Attachment] = (try? Attachment.fetchAll(db, ids: self.attachmentIds)) .defaulting(to: []) .sorted { lhs, rhs in (attachmentIdIndexes[lhs.id] ?? 0) < (attachmentIdIndexes[rhs.id] ?? 0) } - - 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() } dataMessage.setAttachments(attachmentProtos) @@ -190,14 +184,6 @@ public final class VisibleMessage: Message { dataMessage.setReaction(reactionProto) } - // Group context - do { - try setGroupContextIfNeeded(db, on: dataMessage) - } catch { - SNLog("Couldn't construct visible message proto from: \(self).") - return nil - } - // Sync target if let syncTarget = syncTarget { dataMessage.setSyncTarget(syncTarget) @@ -241,7 +227,10 @@ public extension VisibleMessage { sentTimestamp: UInt64(interaction.timestampMs), recipient: (try? interaction.recipientStates.fetchOne(db))?.recipientId, groupPublicKey: try? interaction.thread - .filter(SessionThread.Columns.variant == SessionThread.Variant.closedGroup) + .filter( + SessionThread.Columns.variant == SessionThread.Variant.legacyGroup || + SessionThread.Columns.variant == SessionThread.Variant.group + ) .select(.id) .asRequest(of: String.self) .fetchOne(db), diff --git a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift b/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift deleted file mode 100644 index fb3ac4e41..000000000 --- a/SessionMessagingKit/Open Groups/Models/BatchRequestInfo.swift +++ /dev/null @@ -1,174 +0,0 @@ -// 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/SOGSBatchRequest.swift b/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift new file mode 100644 index 000000000..efe990e89 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Models/SOGSBatchRequest.swift @@ -0,0 +1,75 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import SessionUtilitiesKit + +public extension OpenGroupAPI { + internal struct BatchRequest: Encodable { + let requests: [Child] + + init(requests: [ErasedPreparedSendData]) { + self.requests = requests.map { Child(request: $0) } + } + + // MARK: - Encodable + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + try container.encode(requests) + } + + // MARK: - BatchRequest.Child + + struct Child: Encodable { + enum CodingKeys: String, CodingKey { + case method + case path + case headers + case json + case b64 + case bytes + } + + let request: ErasedPreparedSendData + + func encode(to encoder: Encoder) throws { + try request.encodeForBatchRequest(to: encoder) + } + } + } + + struct BatchResponse: Decodable { + let info: ResponseInfoType + let data: [Endpoint: Decodable] + + public subscript(position: Endpoint) -> Decodable? { + get { return data[position] } + } + + public var count: Int { data.count } + public var keys: Dictionary.Keys { data.keys } + public var values: Dictionary.Values { data.values } + + // MARK: - Initialization + + internal init( + info: ResponseInfoType, + data: [Endpoint: Decodable] + ) { + self.info = info + self.data = data + } + + public init(from decoder: Decoder) throws { +#if DEBUG + preconditionFailure("The `OpenGroupAPI.BatchResponse` type cannot be decoded directly, this is simply here to allow for `PreparedSendData` support") +#else + info = HTTP.ResponseInfo(code: 0, headers: [:]) + data = [:] +#endif + + } + } +} diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index 8ce774b31..b266c26e1 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -67,31 +67,31 @@ extension OpenGroupAPI.Message { // If we have data and a signature (ie. the message isn't a deletion) then validate the signature if let base64EncodedData: String = maybeBase64EncodedData, let base64EncodedSignature: String = maybeBase64EncodedSignature { guard let sender: String = maybeSender, let data = Data(base64Encoded: base64EncodedData), let signature = Data(base64Encoded: base64EncodedSignature) else { - throw HTTP.Error.parsingFailed + throw HTTPError.parsingFailed } guard let dependencies: SMKDependencies = decoder.userInfo[Dependencies.userInfoKey] as? SMKDependencies else { - throw HTTP.Error.parsingFailed + throw HTTPError.parsingFailed } // Verify the signature based on the SessionId.Prefix type let publicKey: Data = Data(hex: sender.removingIdPrefixIfNeeded()) switch SessionId.Prefix(from: sender) { - case .blinded: + case .blinded15, .blinded25: guard dependencies.sign.verify(message: data.bytes, publicKey: publicKey.bytes, signature: signature.bytes) else { SNLog("Ignoring message with invalid signature.") - throw HTTP.Error.parsingFailed + throw HTTPError.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 + throw HTTPError.parsingFailed } case .none: SNLog("Ignoring message with invalid sender.") - throw HTTP.Error.parsingFailed + throw HTTPError.parsingFailed } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index f4af87fe4..3797dd755 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -1,10 +1,9 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import Sodium -import Curve25519Kit import SessionSnodeKit import SessionUtilitiesKit @@ -27,13 +26,13 @@ public enum OpenGroupAPI { /// - Messages (includes additions and deletions) /// - Inbox for the server /// - Outbox for the server - public static func poll( + public static func preparedPoll( _ db: Database, server: String, hasPerformedInitialPoll: Bool, timeSinceLastPoll: TimeInterval, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { + ) throws -> PreparedSendData { let lastInboxMessageId: Int64 = (try? OpenGroup .select(.inboxLatestMessageId) .filter(OpenGroup.Columns.server == server) @@ -52,26 +51,23 @@ public enum OpenGroupAPI { .asRequest(of: Capability.Variant.self) .fetchSet(db)) .defaulting(to: []) + let openGroupRooms: [OpenGroup] = (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: []) - // Generate the requests - let requestResponseType: [BatchRequestInfoType] = [ - BatchRequestInfo( - request: Request( - server: server, - endpoint: .capabilities - ), - responseType: Capabilities.self + let preparedRequests: [ErasedPreparedSendData] = [ + try preparedCapabilities( + db, + server: server, + using: dependencies ) - ] - .appending( + ].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 + contentsOf: try openGroupRooms + .flatMap { openGroup -> [ErasedPreparedSendData] in let shouldRetrieveRecentMessages: Bool = ( openGroup.sequenceNumber == 0 || ( // If it's the first poll for this launch and it's been longer than @@ -83,26 +79,27 @@ public enum OpenGroupAPI { ) return [ - BatchRequestInfo( - request: Request( - server: server, - endpoint: .roomPollInfo(openGroup.roomToken, openGroup.infoUpdates) - ), - responseType: RoomPollInfo.self + try preparedRoomPollInfo( + db, + lastUpdated: openGroup.infoUpdates, + for: openGroup.roomToken, + on: openGroup.server, + using: dependencies ), - BatchRequestInfo( - request: Request( - server: server, - endpoint: (shouldRetrieveRecentMessages ? - .roomMessagesRecent(openGroup.roomToken) : - .roomMessagesSince(openGroup.roomToken, seqNo: openGroup.sequenceNumber) - ), - queryParameters: [ - .updateTypes: UpdateTypes.reaction.rawValue, - .reactors: "5" - ] - ), - responseType: [Failable].self + (shouldRetrieveRecentMessages ? + try preparedRecentMessages( + db, + in: openGroup.roomToken, + on: openGroup.server, + using: dependencies + ) : + try preparedMessagesSince( + db, + seqNo: openGroup.sequenceNumber, + in: openGroup.roomToken, + on: openGroup.server, + using: dependencies + ) ) ] } @@ -113,134 +110,111 @@ public enum OpenGroupAPI { !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 + (lastInboxMessageId == 0 ? + try preparedInbox(db, on: server, using: dependencies) : + try preparedInboxSince(db, id: lastInboxMessageId, on: server, using: dependencies) ), // 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 - ) + (lastOutboxMessageId == 0 ? + try preparedOutbox(db, on: server, using: dependencies) : + try preparedOutboxSince(db, id: lastOutboxMessageId, on: server, using: dependencies) + ), ] ) ) - return OpenGroupAPI.batch(db, server: server, requests: requestResponseType, using: dependencies) + return try OpenGroupAPI.preparedBatch( + db, + server: server, + requests: preparedRequests, + 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) + /// 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( + /// 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 preparedBatch( _ db: Database, server: String, - requests: [BatchRequestInfoType], + requests: [ErasedPreparedSendData], using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { - let requestBody: BatchRequest = requests.map { $0.toSubRequest() } - let responseTypes = requests.map { $0.responseType } - - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, server: server, - endpoint: Endpoint.batch, - body: requestBody + endpoint: .batch, + body: BatchRequest(requests: requests) ), + responseType: BatchResponse.self, 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 + /// 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)." + /// 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( + /// 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 preparedSequence( _ db: Database, server: String, - requests: [BatchRequestInfoType], + requests: [ErasedPreparedSendData], using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<[Endpoint: (OnionRequestResponseInfoType, Codable?)]> { - let requestBody: BatchRequest = requests.map { $0.toSubRequest() } - let responseTypes = requests.map { $0.responseType } - - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, server: server, endpoint: Endpoint.sequence, - body: requestBody + body: BatchRequest(requests: requests) ), + responseType: BatchResponse.self, 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 + /// 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( + public static func preparedCapabilities( _ db: Database, server: String, forceBlinded: Bool = false, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Capabilities)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .capabilities ), + responseType: Capabilities.self, forceBlinded: forceBlinded, using: dependencies ) - .decoded(as: Capabilities.self, on: OpenGroupAPI.workQueue, using: dependencies) } // MARK: - Room @@ -248,114 +222,93 @@ public enum OpenGroupAPI { /// Returns a list of available rooms on the server /// /// Rooms to which the user does not have access (e.g. because they are banned, or the room has restricted access permissions) are not included - public static func rooms( + public static func preparedRooms( _ db: Database, server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, [Room])> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData<[Room]> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .rooms ), + responseType: [Room].self, 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( + public static func preparedRoom( _ db: Database, for roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Room)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .room(roomToken) ), + responseType: Room.self, 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( + public static func preparedRoomPollInfo( _ db: Database, lastUpdated: Int64, for roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, RoomPollInfo)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .roomPollInfo(roomToken, lastUpdated) ), + responseType: RoomPollInfo.self, using: dependencies ) - .decoded(as: RoomPollInfo.self, on: OpenGroupAPI.workQueue, using: dependencies) } + public typealias CapabilitiesAndRoomResponse = ( + capabilities: (info: ResponseInfoType, data: Capabilities), + room: (info: ResponseInfoType, data: Room) + ) + /// 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( + public static func preparedCapabilitiesAndRoom( _ db: Database, for roomToken: String, on server: String, 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( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .preparedSequence( db, server: server, - requests: requestResponseType, + requests: [ + // Get the latest capabilities for the server (in case it's a new server or the + // cached ones are stale) + preparedCapabilities(db, server: server, using: dependencies), + preparedRoom(db, for: roomToken, on: server, using: dependencies) + ], 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 + .map { (info: ResponseInfoType, response: BatchResponse) -> CapabilitiesAndRoomResponse in + let maybeCapabilities: HTTP.BatchSubResponse? = (response[.capabilities] as? HTTP.BatchSubResponse) + let maybeRoomResponse: Decodable? = response.data .first(where: { key, _ in switch key { case .room: return true @@ -363,85 +316,62 @@ public enum OpenGroupAPI { } }) .map { _, value in value } - let maybeRoom: (info: OnionRequestResponseInfoType, data: Room?)? = maybeRoomResponse - .map { info, data in (info, (data as? BatchSubResponse)?.body) } + let maybeRoom: HTTP.BatchSubResponse? = (maybeRoomResponse as? HTTP.BatchSubResponse) 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 - } + let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.responseInfo, + let capabilities: Capabilities = maybeCapabilities?.body, + let roomInfo: ResponseInfoType = maybeRoom?.responseInfo, + let room: Room = maybeRoom?.body + else { throw HTTPError.parsingFailed } return ( - (capabilitiesInfo, capabilities), - (roomInfo, room) + capabilities: (info: capabilitiesInfo, data: capabilities), + room: (info: roomInfo, data: 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( + public static func preparedCapabilitiesAndRooms( _ db: Database, on server: String, 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( + ) throws -> PreparedSendData<(capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room]))> { + return try OpenGroupAPI + .preparedSequence( db, server: server, - requests: requestResponseType, + requests: [ + // Get the latest capabilities for the server (in case it's a new server or the + // cached ones are stale) + preparedCapabilities(db, server: server, using: dependencies), + preparedRooms(db, server: server, using: dependencies) + ], 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 + .map { (info: ResponseInfoType, response: BatchResponse) -> (capabilities: (info: ResponseInfoType, data: Capabilities), rooms: (info: ResponseInfoType, data: [Room])) in + let maybeCapabilities: HTTP.BatchSubResponse? = (response[.capabilities] as? HTTP.BatchSubResponse) + let maybeRooms: HTTP.BatchSubResponse<[Room]>? = response.data .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) } + .map { _, value in value as? HTTP.BatchSubResponse<[Room]> } 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 - } + let capabilitiesInfo: ResponseInfoType = maybeCapabilities?.responseInfo, + let capabilities: Capabilities = maybeCapabilities?.body, + let roomsInfo: ResponseInfoType = maybeRooms?.responseInfo, + let rooms: [Room] = maybeRooms?.body + else { throw HTTPError.parsingFailed } return ( - (capabilitiesInfo, capabilities), - (roomsInfo, rooms) + capabilities: (info: capabilitiesInfo, data: capabilities), + rooms: (info: roomsInfo, data: rooms) ) } } @@ -449,7 +379,7 @@ public enum OpenGroupAPI { // MARK: - Messages /// Posts a new message to a room - public static func send( + public static func preparedSend( _ db: Database, plaintext: Data, to roomToken: String, @@ -458,13 +388,13 @@ public enum OpenGroupAPI { whisperMods: Bool, fileIds: [String]?, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Message)> { + ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { - return Promise(error: OpenGroupAPIError.signingFailed) + throw OpenGroupAPIError.signingFailed } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -478,35 +408,35 @@ public enum OpenGroupAPI { fileIds: fileIds ) ), + responseType: Message.self, using: dependencies ) - .decoded(as: Message.self, on: OpenGroupAPI.workQueue, using: dependencies) } /// Returns a single message by ID - public static func message( + public static func preparedMessage( _ db: Database, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Message)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .roomMessageIndividual(roomToken, id: id) ), + responseType: Message.self, 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( + public static func preparedMessageUpdate( _ db: Database, id: Int64, plaintext: Data, @@ -514,13 +444,13 @@ public enum OpenGroupAPI { in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + ) throws -> PreparedSendData { guard let signResult: (publicKey: String, signature: Bytes) = sign(db, messageBytes: plaintext.bytes, for: server, fallbackSigningType: .standard, using: dependencies) else { - return Promise(error: OpenGroupAPIError.signingFailed) + throw OpenGroupAPIError.signingFailed } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .put, @@ -532,99 +462,115 @@ public enum OpenGroupAPI { fileIds: fileIds ) ), + responseType: NoResponse.self, using: dependencies ) } - public static func messageDelete( + /// Remove a message by its message id + public static func preparedMessageDelete( _ db: Database, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: .roomMessageIndividual(roomToken, id: id) ), + responseType: NoResponse.self, 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( + /// Retrieves recent messages posted to this room + /// + /// Returns the most recent limit messages (100 if no limit is given). This only returns extant messages, and always returns the latest + /// versions: that is, deleted message indicators and pre-editing versions of messages are not returned. Messages are returned in order + /// from most recent to least recent + public static func preparedRecentMessages( _ db: Database, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, [Message])> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData<[Failable]> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, - endpoint: .roomMessagesRecent(roomToken) + endpoint: .roomMessagesRecent(roomToken), + queryParameters: [ + .updateTypes: UpdateTypes.reaction.rawValue, + .reactors: "5" + ] ), + responseType: [Failable].self, 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( + /// Retrieves messages from the room preceding a given id. + /// + /// This endpoint is intended to be used with .../recent to allow a client to retrieve the most recent messages and then walk backwards + /// through batches of ever-older messages. As with .../recent, messages are returned in order from most recent to least recent. + /// + /// As with .../recent, this endpoint does not include deleted messages and always returns the current version, for edited messages. + public static func preparedMessagesBefore( _ db: Database, messageId: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, [Message])> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData<[Failable]> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, - endpoint: .roomMessagesBefore(roomToken, id: messageId) + endpoint: .roomMessagesBefore(roomToken, id: messageId), + queryParameters: [ + .updateTypes: UpdateTypes.reaction.rawValue, + .reactors: "5" + ] ), + responseType: [Failable].self, 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( + /// Retrieves message updates from a room. This is the main message polling endpoint in SOGS. + /// + /// This endpoint retrieves new, edited, and deleted messages or message reactions posted to this room since the given message + /// sequence counter. Returns limit messages at a time (100 if no limit is given). Returned messages include any new messages, updates + /// to existing messages (i.e. edits), and message deletions made to the room since the given update id. Messages are returned in "update" + /// order, that is, in the order in which the change was applied to the room, from oldest the newest. + public static func preparedMessagesSince( _ db: Database, seqNo: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, [Message])> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData<[Failable]> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .roomMessagesSince(roomToken, seqNo: seqNo), queryParameters: [ .updateTypes: UpdateTypes.reaction.rawValue, - .reactors: "20" + .reactors: "5" ] ), + responseType: [Failable].self, 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 @@ -640,133 +586,144 @@ public enum OpenGroupAPI { /// - server: The server to delete messages from /// /// - dependencies: Injected dependencies (used for unit testing) - public static func messagesDeleteAll( + public static func preparedMessagesDeleteAll( _ db: Database, sessionId: String, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: Endpoint.roomDeleteMessages(roomToken, sessionId: sessionId) ), + responseType: NoResponse.self, using: dependencies ) } // MARK: - Reactions - public static func reactors( + /// Returns the list of all reactors who have added a particular reaction to a particular message. + public static func preparedReactors( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise { + ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return Promise(error: OpenGroupAPIError.invalidEmoji) + throw OpenGroupAPIError.invalidEmoji } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .get, server: server, endpoint: .reactors(roomToken, id: id, emoji: encodedEmoji) ), + responseType: NoResponse.self, using: dependencies ) - .map { responseInfo, _ in responseInfo } } - public static func reactionAdd( + /// Adds a reaction to the given message in this room. The user must have read access in the room. + /// + /// Reactions are short strings of 1-12 unicode codepoints, typically emoji (or character sequences to produce an emoji variant, + /// such as 👨🏿‍🦰, which is composed of 4 unicode "characters" but usually renders as a single emoji "Man: Dark Skin Tone, Red Hair"). + public static func preparedReactionAdd( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, ReactionAddResponse)> { + ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return Promise(error: OpenGroupAPIError.invalidEmoji) + throw OpenGroupAPIError.invalidEmoji } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .put, server: server, endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) ), + responseType: ReactionAddResponse.self, using: dependencies ) - .decoded(as: ReactionAddResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) } - public static func reactionDelete( + /// Removes a reaction from a post this room. The user must have read access in the room. This only removes the user's own reaction + /// but does not affect the reactions of other users. + public static func preparedReactionDelete( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, ReactionRemoveResponse)> { + ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return Promise(error: OpenGroupAPIError.invalidEmoji) + throw OpenGroupAPIError.invalidEmoji } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: .reaction(roomToken, id: id, emoji: encodedEmoji) ), + responseType: ReactionRemoveResponse.self, using: dependencies ) - .decoded(as: ReactionRemoveResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) } - public static func reactionDeleteAll( + /// Removes all reactions of all users from a post in this room. The calling must have moderator permissions in the room. This endpoint + /// can either remove a single reaction (e.g. remove all 🍆 reactions) by specifying it after the message id (following a /), or remove all + /// reactions from the post by not including the / suffix of the URL. + public static func preparedReactionDeleteAll( _ db: Database, emoji: String, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, ReactionRemoveAllResponse)> { + ) throws -> PreparedSendData { /// URL(String:) won't convert raw emojis, so need to do a little encoding here. /// The raw emoji will come back when calling url.path guard let encodedEmoji: String = emoji.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else { - return Promise(error: OpenGroupAPIError.invalidEmoji) + throw OpenGroupAPIError.invalidEmoji } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .delete, server: server, endpoint: .reactionDelete(roomToken, id: id, emoji: encodedEmoji) ), + responseType: ReactionRemoveAllResponse.self, using: dependencies ) - .decoded(as: ReactionRemoveAllResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) } // MARK: - Pinning @@ -781,83 +738,89 @@ public enum OpenGroupAPI { /// Pinned messages that are already pinned will be re-pinned (that is, their pin timestamp and pinning admin user will be updated) - because pinned /// messages are returned in pinning-order this allows admins to order multiple pinned messages in a room by re-pinning (via this endpoint) in the /// order in which pinned messages should be displayed - public static func pinMessage( + public static func preparedPinMessage( _ db: Database, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, server: server, endpoint: .roomPinMessage(roomToken, id: id) ), + responseType: NoResponse.self, 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( + public static func preparedUnpinMessage( _ db: Database, id: Int64, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, server: server, endpoint: .roomUnpinMessage(roomToken, id: id) ), + responseType: NoResponse.self, 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( + public static func preparedUnpinAll( _ db: Database, in roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, server: server, endpoint: .roomUnpinAll(roomToken) ), + responseType: NoResponse.self, using: dependencies ) - .map { responseInfo, _ in responseInfo } } // MARK: - Files - public static func uploadFile( + /// Uploads a file to a room. + /// + /// Takes the request as binary in the body and takes other properties (specifically the suggested filename) via submitted headers. + /// + /// The user must have upload and posting permissions for the room. The file will have a default lifetime of 1 hour, which is extended + /// to 15 days (by default) when a post referencing the uploaded file is posted or edited. + public static func preparedUploadFile( _ db: Database, bytes: [UInt8], fileName: String? = nil, to roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, FileUploadResponse)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -871,97 +834,91 @@ public enum OpenGroupAPI { ], body: bytes ), + responseType: FileUploadResponse.self, timeout: FileServerAPI.fileUploadTimeout, using: dependencies ) - .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) } - public static func downloadFile( + /// Retrieves a file uploaded to the room. + /// + /// Retrieves a file via its numeric id from the room, returning the file content directly as the binary response body. The file's suggested + /// filename (as provided by the uploader) is provided in the Content-Disposition header, if available. + public static func preparedDownloadFile( _ db: Database, fileId: String, from roomToken: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Data)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .roomFileIndividual(roomToken, fileId) ), + responseType: Data.self, timeout: FileServerAPI.fileDownloadTimeout, 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( + /// **Note:** `inbox` will return a `304` with an empty response if no messages (hence the optional return type) + public static func preparedInbox( _ db: Database, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData<[DirectMessage]?> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .inbox ), + responseType: [DirectMessage]?.self, 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( + /// **Note:** `inboxSince` will return a `304` with an empty response if no messages (hence the optional return type) + public static func preparedInboxSince( _ db: Database, id: Int64, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData<[DirectMessage]?> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .inboxSince(id: id) ), + responseType: [DirectMessage]?.self, 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( + public static func preparedSend( _ db: Database, ciphertext: Data, toInboxFor blindedSessionId: String, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, SendDirectMessageResponse)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -971,56 +928,50 @@ public enum OpenGroupAPI { message: ciphertext ) ), + responseType: SendDirectMessageResponse.self, 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( + /// **Note:** `outbox` will return a `304` with an empty response if no messages (hence the optional return type) + public static func preparedOutbox( _ db: Database, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData<[DirectMessage]?> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .outbox ), + responseType: [DirectMessage]?.self, 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( + /// **Note:** `outboxSince` will return a `304` with an empty response if no messages (hence the optional return type) + public static func preparedOutboxSince( _ db: Database, id: Int64, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, [DirectMessage]?)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData<[DirectMessage]?> { + return try OpenGroupAPI + .prepareSendData( db, request: Request( server: server, endpoint: .outboxSince(id: id) ), + responseType: [DirectMessage]?.self, using: dependencies ) - .decoded(as: [DirectMessage]?.self, on: OpenGroupAPI.workQueue, using: dependencies) } // MARK: - Users @@ -1056,16 +1007,16 @@ public enum OpenGroupAPI { /// - server: The server to delete messages from /// /// - dependencies: Injected dependencies (used for unit testing) - public static func userBan( + public static func preparedUserBan( _ 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( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -1077,6 +1028,7 @@ public enum OpenGroupAPI { timeout: timeout ) ), + responseType: NoResponse.self, using: dependencies ) } @@ -1105,15 +1057,15 @@ public enum OpenGroupAPI { /// - server: The server to delete messages from /// /// - dependencies: Injected dependencies (used for unit testing) - public static func userUnban( + public static func preparedUserUnban( _ db: Database, sessionId: String, from roomTokens: [String]?, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - return OpenGroupAPI - .send( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -1124,6 +1076,7 @@ public enum OpenGroupAPI { global: (roomTokens == nil ? true : nil) ) ), + responseType: NoResponse.self, using: dependencies ) } @@ -1179,7 +1132,7 @@ public enum OpenGroupAPI { /// - server: The server to perform the permission changes on /// /// - dependencies: Injected dependencies (used for unit testing) - public static func userModeratorUpdate( + public static func preparedUserModeratorUpdate( _ db: Database, sessionId: String, moderator: Bool? = nil, @@ -1188,13 +1141,13 @@ public enum OpenGroupAPI { for roomTokens: [String]?, on server: String, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Data?)> { + ) throws -> PreparedSendData { guard (moderator != nil && admin == nil) || (moderator == nil && admin != nil) else { - return Promise(error: HTTP.Error.generic) + throw HTTPError.generic } - return OpenGroupAPI - .send( + return try OpenGroupAPI + .prepareSendData( db, request: Request( method: .post, @@ -1208,52 +1161,42 @@ public enum OpenGroupAPI { visible: visible ) ), + responseType: NoResponse.self, 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( + public static func preparedUserBanAndDeleteAllMessages( _ 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( + ) throws -> PreparedSendData { + return try OpenGroupAPI + .preparedSequence( db, server: server, - requests: requestResponseType, + requests: [ + preparedUserBan( + db, + sessionId: sessionId, + from: [roomToken], + on: server, + using: dependencies + ), + preparedMessagesDeleteAll( + db, + sessionId: sessionId, + in: roomToken, + on: server, + using: dependencies + ) + ], using: dependencies ) - .map { $0.values.map { responseInfo, _ in responseInfo } } } // MARK: - Authentication @@ -1268,7 +1211,7 @@ public enum OpenGroupAPI { using dependencies: SMKDependencies = SMKDependencies() ) -> (publicKey: String, signature: Bytes)? { guard - let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), + let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), let serverPublicKey: String = try? OpenGroup .select(.publicKey) .filter(OpenGroup.Columns.server == serverName.lowercased()) @@ -1285,27 +1228,27 @@ public enum OpenGroupAPI { // If we have no capabilities or if the server supports blinded keys then sign using the blinded key if forceBlinded || capabilities.isEmpty || capabilities.contains(.blind) { - guard let blindedKeyPair: Box.KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + guard let blindedKeyPair: 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, + publicKey: SessionId(.blinded15, 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 @@ -1313,7 +1256,7 @@ public enum OpenGroupAPI { // Default to using the 'standard' key default: - guard let userKeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else { return nil } + guard let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db) else { return nil } guard let signatureResult: Bytes = try? dependencies.ed25519.sign(data: messageBytes, keyPair: userKeyPair) else { return nil } @@ -1378,10 +1321,10 @@ public enum OpenGroupAPI { 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() + HTTPHeader.sogsPubKey: signResult.publicKey, + HTTPHeader.sogsTimestamp: "\(timestamp)", + HTTPHeader.sogsNonce: nonce.base64EncodedString(), + HTTPHeader.sogsSignature: signResult.signature.toBase64() ]) return updatedRequest @@ -1389,35 +1332,58 @@ public enum OpenGroupAPI { // MARK: - Convenience - private static func send( + /// Takes the reuqest information and generates a signed `PreparedSendData` pbject which is ready for sending to the API, this + /// method is mainly here so we can separate the preparation of a request, which requires access to the database for signing, from the + /// actual sending of the reuqest to ensure we don't run into any unexpected blocking of the database write thread + private static func prepareSendData( _ db: Database, request: Request, + responseType: R.Type, forceBlinded: Bool = false, - timeout: TimeInterval = HTTP.timeout, + timeout: TimeInterval = HTTP.defaultTimeout, using dependencies: SMKDependencies = SMKDependencies() - ) -> Promise<(OnionRequestResponseInfoType, Data?)> { - let urlRequest: URLRequest - - do { - urlRequest = try request.generateUrlRequest() - } - catch { - return Promise(error: error) - } - + ) throws -> PreparedSendData { + let urlRequest: URLRequest = try request.generateUrlRequest() 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) } + guard let publicKey: String = maybePublicKey else { throw OpenGroupAPIError.noPublicKey } // Attempt to sign the request with the new auth guard let signedRequest: URLRequest = sign(db, request: urlRequest, for: request.server, with: publicKey, forceBlinded: forceBlinded, using: dependencies) else { - return Promise(error: OpenGroupAPIError.signingFailed) + throw OpenGroupAPIError.signingFailed } - return dependencies.onionApi.sendOnionRequest(signedRequest, to: request.server, with: publicKey, timeout: timeout) + return PreparedSendData( + request: request, + urlRequest: signedRequest, + publicKey: publicKey, + responseType: responseType, + timeout: timeout + ) + } + + /// This method takes in the `PreparedSendData` and actually sends it to the API + public static func send( + data: PreparedSendData?, + using dependencies: SMKDependencies = SMKDependencies() + ) -> AnyPublisher<(ResponseInfoType, R), Error> { + guard let validData: PreparedSendData = data else { + return Fail(error: OpenGroupAPIError.invalidPreparedData) + .eraseToAnyPublisher() + } + + return dependencies.onionApi + .sendOnionRequest( + validData.request, + to: validData.server, + with: validData.publicKey, + timeout: validData.timeout + ) + .decoded(with: validData, using: dependencies) + .eraseToAnyPublisher() } } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index e2076f350..72d3e023b 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -1,38 +1,22 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine 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 } - - var pendingChanges: [OpenGroupAPI.PendingChange] { get set } - - func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval -} - // MARK: - OpenGroupManager -@objc(SNOpenGroupManager) -public final class OpenGroupManager: NSObject { +public final class OpenGroupManager { + public typealias DefaultRoomInfo = (room: OpenGroupAPI.Room, existingImageData: Data?) + // MARK: - Cache - public class Cache: OGMCacheType { - public var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? - public var groupImagePromises: [String: Promise] = [:] + public class Cache: OGMMutableCacheType { + public var defaultRoomsPublisher: AnyPublisher<[DefaultRoomInfo], Error>? + public var groupImagePublishers: [String: AnyPublisher] = [:] public var pollers: [String: OpenGroupAPI.Poller] = [:] // One for each server public var isPolling: Bool = false @@ -61,44 +45,42 @@ public final class OpenGroupManager: NSObject { // 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()) + public static let shared: OpenGroupManager = OpenGroupManager() // MARK: - Polling public func startPolling(using dependencies: OGMDependencies = OGMDependencies()) { - guard !dependencies.cache.isPolling else { return } + // Run on the 'workQueue' to ensure any 'Atomic' access doesn't block the main thread + // on startup + OpenGroupAPI.workQueue.async { + 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()) + 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: []) - // 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) } + // Update the cache state and re-create all of the pollers + 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()) + } + } + + // Now that the pollers have been created actually start them + dependencies.cache.pollers.forEach { _, poller in poller.startIfNeeded(using: dependencies) } } } @@ -182,7 +164,7 @@ public final class OpenGroupManager: NSObject { } // First check if there is no poller for the specified server - if serverOptions.first(where: { dependencies.cache.pollers[$0] != nil }) == nil { + if Set(dependencies.cache.pollers.keys).intersection(serverOptions).isEmpty { return false } @@ -199,11 +181,18 @@ public final class OpenGroupManager: NSObject { return hasExistingThread } - public func add(_ db: Database, roomToken: String, server: String, publicKey: String, isConfigMessage: Bool, dependencies: OGMDependencies = OGMDependencies()) -> Promise { + public func add( + _ db: Database, + roomToken: String, + server: String, + publicKey: String, + calledFromConfigHandling: Bool, + dependencies: OGMDependencies = OGMDependencies() + ) -> Bool { // 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(()) + SNLog("Ignoring join open group attempt (already joined), user initiated: \(!calledFromConfigHandling)") + return false } // Store the open group information @@ -217,9 +206,19 @@ public final class OpenGroupManager: NSObject { 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)) + // inactive one but that won't matter as we then activate it) + _ = try? SessionThread + .fetchOrCreate( + db, + id: threadId, + variant: .community, + /// If we didn't add this open group via config handling then flag it to be visible (if it did come via config handling then + /// we want to wait until it actually has messages before making it visible) + /// + /// **Note:** We **MUST** provide a `nil` value if this method was called from the config handling as updating + /// the `shouldVeVisible` state can trigger a config update which could result in an infinite loop in the future + shouldBeVisible: (calledFromConfigHandling ? nil : true) + ) if (try? OpenGroup.exists(db, id: threadId)) == false { try? OpenGroup @@ -229,34 +228,75 @@ public final class OpenGroupManager: NSObject { // 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) - ) + if calledFromConfigHandling { + _ = try? OpenGroup + .filter(id: OpenGroup.idFor(roomToken: roomToken, server: targetServer)) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + OpenGroup.Columns.isActive.set(to: true), + OpenGroup.Columns.sequenceNumber.set(to: 0) + ) + } + else { + _ = try? OpenGroup + .filter(id: OpenGroup.idFor(roomToken: roomToken, server: targetServer)) + .updateAllAndConfig( + db, + OpenGroup.Columns.isActive.set(to: true), + OpenGroup.Columns.sequenceNumber.set(to: 0) + ) + } - let (promise, seal) = Promise.pending() + return true + } + + public func performInitialRequestsAfterAdd( + successfullyAddedGroup: Bool, + roomToken: String, + server: String, + publicKey: String, + calledFromConfigHandling: Bool, + dependencies: OGMDependencies = OGMDependencies() + ) -> AnyPublisher { + guard successfullyAddedGroup else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } - // 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, - using: dependencies - ) - } - .done(on: OpenGroupAPI.workQueue) { response in + // Store the open group information + let targetServer: String = { + guard OpenGroupManager.isSessionRunOpenGroup(server: server) else { + return server.lowercased() + } + + return OpenGroupAPI.defaultServer + }() + + return dependencies.storage + .readPublisher { db in + try OpenGroupAPI + .preparedCapabilitiesAndRoom( + db, + for: roomToken, + on: targetServer, + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .flatMap { info, response -> Future in + Future { resolver in dependencies.storage.write { db in + // Add the new open group to libSession + if !calledFromConfigHandling { + try SessionUtil.add( + db, + server: server, + rootToken: roomToken, + publicKey: publicKey + ) + } + // Store the capabilities first OpenGroupManager.handleCapabilities( db, @@ -273,26 +313,38 @@ public final class OpenGroupManager: NSObject { on: targetServer, dependencies: dependencies ) { - seal.fulfill(()) + resolver(Result.success(())) } } } - .catch(on: DispatchQueue.global(qos: .userInitiated)) { error in - SNLog("Failed to join open group.") - seal.reject(error) + } + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Failed to join open group.") + } } - .retainUntilComplete() - } - - return promise + ) + .eraseToAnyPublisher() } - public func delete(_ db: Database, openGroupId: String, dependencies: OGMDependencies = OGMDependencies()) { + public func delete( + _ db: Database, + openGroupId: String, + calledFromConfigHandling: Bool, + using dependencies: OGMDependencies = OGMDependencies() + ) { let server: String? = try? OpenGroup .select(.server) .filter(id: openGroupId) .asRequest(of: String.self) .fetchOne(db) + let roomToken: String? = try? OpenGroup + .select(.roomToken) + .filter(id: openGroupId) + .asRequest(of: String.self) + .fetchOne(db) // Stop the poller if needed // @@ -316,6 +368,12 @@ public final class OpenGroupManager: NSObject { .filter(id: openGroupId) .deleteAll(db) + // Remove any MessageProcessRecord entries (we will want to reprocess all OpenGroup messages + // if they get re-added) + _ = try? ControlMessageProcessRecord + .filter(ControlMessageProcessRecord.Columns.threadId == openGroupId) + .deleteAll(db) + // Remove the open group (no foreign key to the thread so it won't auto-delete) if server?.lowercased() != OpenGroupAPI.defaultServer.lowercased() { _ = try? OpenGroup @@ -326,13 +384,17 @@ public final class OpenGroupManager: NSObject { // 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)) + .updateAllAndConfig(db, OpenGroup.Columns.isActive.set(to: false)) } // Remove the thread and associated data _ = try? SessionThread .filter(id: openGroupId) .deleteAll(db) + + if !calledFromConfigHandling, let server: String = server, let roomToken: String = roomToken { + try? SessionUtil.remove(db, server: server, roomToken: roomToken) + } } // MARK: - Response Processing @@ -383,43 +445,34 @@ public final class OpenGroupManager: NSObject { // 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) - let permissions = OpenGroup.Permissions(roomInfo: pollInfo) - + let hasDetails: Bool = (pollInfo.details != nil) + let permissions: OpenGroup.Permissions = OpenGroup.Permissions(roomInfo: pollInfo) + let changes: [ConfigColumnAssignment] = [] + .appending(openGroup.publicKey == maybePublicKey ? nil : + maybePublicKey.map { OpenGroup.Columns.publicKey.set(to: $0) } + ) + .appending(openGroup.userCount == pollInfo.activeUsers ? nil : + OpenGroup.Columns.userCount.set(to: pollInfo.activeUsers) + ) + .appending(openGroup.permissions == permissions ? nil : + OpenGroup.Columns.permissions.set(to: permissions) + ) + .appending(!hasDetails || openGroup.name == pollInfo.details?.name ? nil : + OpenGroup.Columns.name.set(to: pollInfo.details?.name) + ) + .appending(!hasDetails || openGroup.roomDescription == pollInfo.details?.roomDescription ? nil : + OpenGroup.Columns.roomDescription.set(to: pollInfo.details?.roomDescription) + ) + .appending(!hasDetails || openGroup.imageId == pollInfo.details?.imageId ? nil : + OpenGroup.Columns.imageId.set(to: pollInfo.details?.imageId) + ) + .appending(!hasDetails || openGroup.infoUpdates == pollInfo.details?.infoUpdates ? nil : + OpenGroup.Columns.infoUpdates.set(to: pollInfo.details?.infoUpdates) + ) + 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 - ), - (openGroup.permissions != permissions ? - OpenGroup.Columns.permissions.set(to: permissions) : - nil - ) - ].compactMap { $0 } - ) + .updateAllAndConfig(db, changes) // Update the admin/moderator group members if let roomDetails: OpenGroupAPI.Room = pollInfo.details { @@ -428,91 +481,105 @@ public final class OpenGroupManager: NSObject { .deleteAll(db) try roomDetails.admins.forEach { adminId in - _ = try GroupMember( + try GroupMember( groupId: threadId, profileId: adminId, role: .admin, isHidden: false - ).saved(db) + ).save(db) } try roomDetails.hiddenAdmins .defaulting(to: []) .forEach { adminId in - _ = try GroupMember( + try GroupMember( groupId: threadId, profileId: adminId, role: .admin, isHidden: true - ).saved(db) + ).save(db) } try roomDetails.moderators.forEach { moderatorId in - _ = try GroupMember( + try GroupMember( groupId: threadId, profileId: moderatorId, role: .moderator, isHidden: false - ).saved(db) + ).save(db) } try roomDetails.hiddenModerators .defaulting(to: []) .forEach { moderatorId in - _ = try GroupMember( + try GroupMember( groupId: threadId, profileId: moderatorId, role: .moderator, isHidden: true - ).saved(db) + ).save(db) } } - db.afterNextTransaction { 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) + db.afterNextTransactionNested { _ in + // Dispatch async to the workQueue to prevent holding up the DBWrite thread from the + // above transaction + OpenGroupAPI.workQueue.async { + // Start the poller if needed + if dependencies.cache.pollers[server.lowercased()] == nil { + dependencies.mutableCache.mutate { + $0.pollers[server.lowercased()]?.stop() + $0.pollers[server.lowercased()] = OpenGroupAPI.Poller(for: server.lowercased()) + } + + dependencies.cache.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?() + + /// Start downloading the room image (if we don't have one or it's been updated) + if + let imageId: String = (pollInfo.details?.imageId ?? openGroup.imageId), + ( + openGroup.imageData == nil || + openGroup.imageId != imageId + ) + { + OpenGroupManager + .roomImage( + fileId: imageId, + for: roomToken, + on: server, + existingData: openGroup.imageData, + using: dependencies + ) + // Note: We need to subscribe and receive on different threads to ensure the + // logic in 'receiveValue' doesn't result in a reentrancy database issue + .subscribe(on: OpenGroupAPI.workQueue) + .receive(on: DispatchQueue.global(qos: .default)) + .sinkUntilComplete( + receiveCompletion: { _ in + if waitForImageToComplete { + completion?() + } + }, + receiveValue: { data in + dependencies.storage.write { db in + _ = try OpenGroup + .filter(id: threadId) + .updateAll(db, OpenGroup.Columns.imageData.set(to: data)) + } } - } - } - .catch { _ in - if waitForImageToComplete { - completion?() - } - } - .retainUntilComplete() - } - else if waitForImageToComplete { + ) + } + 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?() } - - // If we want to wait for the image to complete then don't call the completion here - guard !waitForImageToComplete else { return } - - // Finish - completion?() } } @@ -523,38 +590,25 @@ public final class OpenGroupManager: NSObject { on server: String, 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 seqNo: Int64? = messages.map { $0.seqNo }.max() + // 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.Message] = messages .filter { $0.deleted != true } .sorted { lhs, rhs in lhs.id < rhs.id } - var messageServerIdsToRemove: [Int64] = messages + var messageServerInfoToRemove: [(id: Int64, seqNo: Int64)] = messages .filter { $0.deleted == true } - .map { $0.id } - - if let seqNo: Int64 = seqNo { - // Update the 'openGroupSequenceNumber' value (Note: SOGS V4 uses the 'seqNo' instead of the 'serverId') - _ = try? OpenGroup - .filter(id: openGroup.id) - .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: seqNo)) - - // Update pendingChange cache - dependencies.mutableCache.mutate { - $0.pendingChanges = $0.pendingChanges - .filter { $0.seqNo == nil || $0.seqNo! > seqNo } - } - } + .map { ($0.id, $0.seqNo) } + var largestValidSeqNo: Int64 = openGroup.sequenceNumber // Process the messages sortedMessages.forEach { message in if message.base64EncodedData == nil && message.reactions == nil { - messageServerIdsToRemove.append(Int64(message.id)) + messageServerInfoToRemove.append((message.id, message.seqNo)) return } @@ -575,12 +629,14 @@ public final class OpenGroupManager: NSObject { if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo { try MessageReceiver.handle( db, + threadId: openGroup.id, + threadVariant: .community, message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - openGroupId: openGroup.id, dependencies: dependencies ) + largestValidSeqNo = max(largestValidSeqNo, message.seqNo) } } catch { @@ -625,6 +681,7 @@ public final class OpenGroupManager: NSObject { openGroupMessageServerId: message.id, openGroupReactions: reactions ) + largestValidSeqNo = max(largestValidSeqNo, message.seqNo) } catch { SNLog("Couldn't handle open group reactions due to error: \(error).") @@ -633,12 +690,28 @@ public final class OpenGroupManager: NSObject { } // Handle any deletions that are needed - guard !messageServerIdsToRemove.isEmpty else { return } + if !messageServerInfoToRemove.isEmpty { + let messageServerIdsToRemove: [Int64] = messageServerInfoToRemove.map { $0.id } + _ = try? Interaction + .filter(Interaction.Columns.threadId == openGroup.threadId) + .filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId)) + .deleteAll(db) + + // Update the seqNo for deletions + largestValidSeqNo = max(largestValidSeqNo, (messageServerInfoToRemove.map({ $0.seqNo }).max() ?? 0)) + } - _ = try? Interaction - .filter(Interaction.Columns.threadId == openGroup.threadId) - .filter(messageServerIdsToRemove.contains(Interaction.Columns.openGroupServerMessageId)) - .deleteAll(db) + // Now that we've finished processing all valid message changes we can update the `sequenceNumber` to + // the `largestValidSeqNo` value + _ = try? OpenGroup + .filter(id: openGroup.id) + .updateAll(db, OpenGroup.Columns.sequenceNumber.set(to: largestValidSeqNo)) + + // Update pendingChange cache based on the `largestValidSeqNo` value + dependencies.mutableCache.mutate { + $0.pendingChanges = $0.pendingChanges + .filter { $0.seqNo == nil || $0.seqNo! > largestValidSeqNo } + } } internal static func handleDirectMessages( @@ -739,10 +812,11 @@ public final class OpenGroupManager: NSObject { if let messageInfo: MessageReceiveJob.Details.MessageInfo = processedMessage?.messageInfo { try MessageReceiver.handle( db, + threadId: (lookup.sessionId ?? lookup.blindedId), + threadVariant: .contact, // Technically not open group messages message: messageInfo.message, serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, associatedWithProto: try SNProtoContent.parseData(messageInfo.serializedProtoData), - openGroupId: nil, // Intentionally nil as they are technically not open group messages dependencies: dependencies ) } @@ -816,26 +890,28 @@ public final class OpenGroupManager: NSObject { } /// This method specifies if the given capability is supported on a specified Open Group - public static func isOpenGroupSupport( - _ capability: Capability.Variant, + public static func doesOpenGroupSupport( + _ db: Database? = nil, + capability: Capability.Variant, on server: String?, using dependencies: OGMDependencies = OGMDependencies() ) -> Bool { guard let server: String = server else { return false } + guard let db: Database = db else { + return dependencies.storage + .read { db in doesOpenGroupSupport(db, capability: capability, on: server, using: dependencies) } + .defaulting(to: false) + } - return dependencies.storage - .read { db in - 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: []) + 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: []) - return capabilities.contains(capability) - } - .defaulting(to: false) + return capabilities.contains(capability) } /// This method specifies if the given publicKey is a moderator or an admin within a specified Open Group @@ -851,13 +927,12 @@ public final class OpenGroupManager: NSObject { let targetRoles: [GroupMember.Role] = [.moderator, .admin] return dependencies.storage - .read { db in - let isDirectModOrAdmin: Bool = (try? GroupMember + .read { db -> Bool in + let isDirectModOrAdmin: Bool = GroupMember .filter(GroupMember.Columns.groupId == groupId) .filter(GroupMember.Columns.profileId == publicKey) .filter(targetRoles.contains(GroupMember.Columns.role)) - .isNotEmpty(db)) - .defaulting(to: false) + .isNotEmpty(db) // If the publicKey provided matches a mod or admin directly then just return immediately if isDirectModOrAdmin { return true } @@ -876,7 +951,7 @@ public final class OpenGroupManager: NSObject { fallthrough case .unblinded: - guard let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db) else { + guard let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { return false } guard sessionId.prefix != .unblinded || publicKey == SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString else { @@ -884,23 +959,28 @@ public final class OpenGroupManager: NSObject { } fallthrough - case .blinded: + case .blinded15, .blinded25: guard - let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), + let userEdKeyPair: 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( + let blindedKeyPair: 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 - } + guard + ( + sessionId.prefix != .blinded15 && + sessionId.prefix != .blinded25 + ) || + publicKey == SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString || + publicKey == SessionId(.blinded25, 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 @@ -908,113 +988,136 @@ public final class OpenGroupManager: NSObject { let possibleKeys: Set = Set([ userPublicKey, SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, - SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString + SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString, + SessionId(.blinded25, publicKey: blindedKeyPair.publicKey).hexString ]) - return (try? GroupMember + return GroupMember .filter(GroupMember.Columns.groupId == groupId) .filter(possibleKeys.contains(GroupMember.Columns.profileId)) .filter(targetRoles.contains(GroupMember.Columns.role)) - .isNotEmpty(db)) - .defaulting(to: false) + .isNotEmpty(db) } } .defaulting(to: false) } - @discardableResult public static func getDefaultRoomsIfNeeded(using dependencies: OGMDependencies = OGMDependencies()) -> Promise<[OpenGroupAPI.Room]> { + @discardableResult public static func getDefaultRoomsIfNeeded( + using dependencies: OGMDependencies = OGMDependencies( + subscribeQueue: OpenGroupAPI.workQueue, + receiveQueue: OpenGroupAPI.workQueue + ) + ) -> AnyPublisher<[DefaultRoomInfo], Error> { // 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 + if let existingPublisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.cache.defaultRoomsPublisher { + return existingPublisher } - 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( + let publisher: AnyPublisher<[DefaultRoomInfo], Error> = dependencies.storage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRooms( db, on: OpenGroupAPI.defaultServer, 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) + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .subscribe(on: dependencies.subscribeQueue) + .receive(on: dependencies.receiveQueue) + .retry(8) + .map { info, response -> [DefaultRoomInfo]? in + dependencies.storage.write { db -> [DefaultRoomInfo] in + // Store the capabilities first + OpenGroupManager.handleCapabilities( + db, + capabilities: response.capabilities.data, + on: OpenGroupAPI.defaultServer + ) + + // Then the rooms + return response.rooms.data + .map { room -> DefaultRoomInfo 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 {} + + // Retrieve existing image data if we have it + let existingImageData: Data? = try? OpenGroup + .select(.imageData) + .filter(id: OpenGroup.idFor(roomToken: room.token, server: OpenGroupAPI.defaultServer)) + .asRequest(of: Data.self) + .fetchOne(db) + + return (room, existingImageData) } - catch {} + } + } + .map { ($0 ?? []) } + .handleEvents( + receiveOutput: { roomInfo in + roomInfo.forEach { room, existingImageData in + guard let imageId: String = room.imageId else { return } - guard let imageId: String = room.imageId else { return nil } - - return (imageId, room.token) - } - .forEach { imageId, roomToken in roomImage( - db, fileId: imageId, - for: roomToken, + for: room.token, on: OpenGroupAPI.defaultServer, + existingData: existingImageData, 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() + }, + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: + dependencies.mutableCache.mutate { cache in + cache.defaultRoomsPublisher = nil + } + } + } + ) + .shareReplay(1) + .eraseToAnyPublisher() dependencies.mutableCache.mutate { cache in - cache.defaultRoomsPromise = promise + cache.defaultRoomsPublisher = publisher } - return promise + // Hold on to the publisher until it has completed at least once + publisher.sinkUntilComplete() + + return publisher } - public static func roomImage( - _ db: Database, + @discardableResult public static func roomImage( fileId: String, for roomToken: String, on server: String, - using dependencies: OGMDependencies = OGMDependencies() - ) -> Promise { + existingData: Data?, + using dependencies: OGMDependencies = OGMDependencies( + subscribeQueue: .global(qos: .background) + ) + ) -> AnyPublisher { // 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 @@ -1029,109 +1132,176 @@ public final class OpenGroupManager: NSObject { let now: Date = dependencies.date let timeSinceLastUpdate: TimeInterval = (lastOpenGroupImageUpdate.map { now.timeIntervalSince($0) } ?? .greatestFiniteMagnitude) let updateInterval: TimeInterval = (7 * 24 * 60 * 60) + let canUseExistingImage: Bool = ( + server.lowercased() == OpenGroupAPI.defaultServer && + timeSinceLastUpdate < updateInterval + ) - 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 + if canUseExistingImage, let data: Data = existingData { + return Just(data) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - let (promise, seal) = Promise.pending() + if let publisher: AnyPublisher = dependencies.cache.groupImagePublishers[threadId] { + return publisher + } - // 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 + // Defer the actual download and run it on a separate thread to avoid blocking the calling thread + let publisher: AnyPublisher = Deferred { + Future { resolver in + dependencies.subscribeQueue.async { + // Hold on to the publisher until it has completed at least once + dependencies.storage + .readPublisher { db -> (Data?, OpenGroupAPI.PreparedSendData?) in + if canUseExistingImage { + let maybeExistingData: Data? = try? OpenGroup + .select(.imageData) + .filter(id: threadId) + .asRequest(of: Data.self) + .fetchOne(db) + + if let existingData: Data = maybeExistingData { + return (existingData, nil) + } + } + + return ( + nil, + try OpenGroupAPI + .preparedDownloadFile( + db, + fileId: fileId, + from: roomToken, + on: server, + using: dependencies + ) + ) + } + .flatMap { info in + switch info { + case (.some(let existingData), _): + return Just(existingData) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + + case (_, .some(let sendData)): + return OpenGroupAPI.send(data: sendData, using: dependencies) + .map { _, imageData in imageData } + .eraseToAnyPublisher() + + default: + return Fail(error: HTTPError.generic) + .eraseToAnyPublisher() + } + } + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): resolver(Result.failure(error)) + } + }, + receiveValue: { 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 + } + + resolver(Result.success(imageData)) + } ) } - .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() + } } + .shareReplay(1) + .eraseToAnyPublisher() + + // Automatically subscribe for the roomImage download (want to download regardless of + // whether the upstream subscribes) + publisher + .subscribe(on: dependencies.subscribeQueue) + .sinkUntilComplete() dependencies.mutableCache.mutate { cache in - cache.groupImagePromises[threadId] = promise + cache.groupImagePublishers[threadId] = publisher } - 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 ?? string.split(separator: "/").first.map({ 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) + return publisher } } +// MARK: - OGMCacheType + +public protocol OGMMutableCacheType: OGMCacheType { + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get set } + var groupImagePublishers: [String: AnyPublisher] { 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 } + + var pendingChanges: [OpenGroupAPI.PendingChange] { get set } + + func getTimeSinceLastOpen(using dependencies: Dependencies) -> TimeInterval +} + +/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a +/// non-thread-safe way +public protocol OGMCacheType { + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { get } + var groupImagePublishers: [String: AnyPublisher] { get } + + var pollers: [String: OpenGroupAPI.Poller] { get } + var isPolling: Bool { get } + + var hasPerformedInitialPoll: [String: Bool] { get } + var timeSinceLastPoll: [String: TimeInterval] { get } + + var pendingChanges: [OpenGroupAPI.PendingChange] { get } +} // 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.mutate { $0 = newValue } } + /// These should not be accessed directly but rather via an instance of this type + private static let _cacheInstance: OGMMutableCacheType = OpenGroupManager.Cache() + private static let _cacheInstanceAccessQueue = DispatchQueue(label: "OGMCacheInstanceAccess") + + internal var _mutableCache: Atomic + public var mutableCache: Atomic { + get { + Dependencies.getMutableValueSettingIfNull(&_mutableCache) { + OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance } + } + } + } + public var cache: OGMCacheType { + get { + Dependencies.getValueSettingIfNull(&_mutableCache) { + OGMDependencies._cacheInstanceAccessQueue.sync { OGMDependencies._cacheInstance } + } + } + set { + guard let mutableValue: OGMMutableCacheType = newValue as? OGMMutableCacheType else { return } + + _mutableCache.mutate { $0 = mutableValue } + } } - public var cache: OGMCacheType { return mutableCache.wrappedValue } - public init( - cache: Atomic? = nil, + subscribeQueue: DispatchQueue? = nil, + receiveQueue: DispatchQueue? = nil, + cache: OGMMutableCacheType? = nil, onionApi: OnionRequestAPIType.Type? = nil, - generalCache: Atomic? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, @@ -1148,6 +1318,8 @@ extension OpenGroupManager { _mutableCache = Atomic(cache) super.init( + subscribeQueue: subscribeQueue, + receiveQueue: receiveQueue, onionApi: onionApi, generalCache: generalCache, storage: storage, diff --git a/SessionMessagingKit/Open Groups/Types/HTTPHeader+OpenGroup.swift b/SessionMessagingKit/Open Groups/Types/HTTPHeader+OpenGroup.swift new file mode 100644 index 000000000..9b844a9bf --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/HTTPHeader+OpenGroup.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension HTTPHeader { + static let sogsPubKey: HTTPHeader = "X-SOGS-Pubkey" + static let sogsNonce: HTTPHeader = "X-SOGS-Nonce" + static let sogsTimestamp: HTTPHeader = "X-SOGS-Timestamp" + static let sogsSignature: HTTPHeader = "X-SOGS-Signature" +} diff --git a/SessionMessagingKit/Open Groups/Types/HTTPQueryParam+OpenGroup.swift b/SessionMessagingKit/Open Groups/Types/HTTPQueryParam+OpenGroup.swift new file mode 100644 index 000000000..eac835a7f --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/HTTPQueryParam+OpenGroup.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public extension HTTPQueryParam { + static let publicKey: HTTPQueryParam = "public_key" + static let fromServerId: HTTPQueryParam = "from_server_id" + + static let required: HTTPQueryParam = "required" + + /// For messages - number between 1 and 256 (default is 100) + static let limit: HTTPQueryParam = "limit" + + /// For file server session version check + static let platform: HTTPQueryParam = "platform" + + /// String indicating the types of updates that the client supports + static let updateTypes: HTTPQueryParam = "t" + static let reactors: HTTPQueryParam = "reactors" +} diff --git a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift index fa427f86f..e1a8945da 100644 --- a/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift +++ b/SessionMessagingKit/Open Groups/Types/OpenGroupAPIError.swift @@ -7,6 +7,8 @@ public enum OpenGroupAPIError: LocalizedError { case signingFailed case noPublicKey case invalidEmoji + case invalidPreparedData + case invalidPoll public var errorDescription: String? { switch self { @@ -14,6 +16,8 @@ public enum OpenGroupAPIError: LocalizedError { case .signingFailed: return "Couldn't sign message." case .noPublicKey: return "Couldn't find server public key." case .invalidEmoji: return "The emoji is invalid." + case .invalidPreparedData: return "Invalid PreparedSendData provided." + case .invalidPoll: return "Poller in invalid state." } } } diff --git a/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift new file mode 100644 index 000000000..c8b7c4d00 --- /dev/null +++ b/SessionMessagingKit/Open Groups/Types/PreparedSendData.swift @@ -0,0 +1,243 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import SessionUtilitiesKit + +// MARK: - ErasedPreparedSendData + +public protocol ErasedPreparedSendData { + var endpoint: OpenGroupAPI.Endpoint { get } + var batchResponseTypes: [Decodable.Type] { get } + + func encodeForBatchRequest(to encoder: Encoder) throws +} + +// MARK: - PreparedSendData + +public extension OpenGroupAPI { + struct PreparedSendData: ErasedPreparedSendData { + internal let request: URLRequest + internal let server: String + internal let publicKey: String + internal let originalType: Decodable.Type + internal let responseType: R.Type + internal let timeout: TimeInterval + fileprivate let responseConverter: ((ResponseInfoType, Any) throws -> R) + + // The following types are needed for `BatchRequest` handling + private let method: HTTPMethod + private let path: String + public let endpoint: Endpoint + fileprivate let batchEndpoints: [Endpoint] + public let batchResponseTypes: [Decodable.Type] + + /// The `jsonBodyEncoder` is used to simplify the encoding for `BatchRequest` + private let jsonBodyEncoder: ((inout KeyedEncodingContainer, BatchRequest.Child.CodingKeys) throws -> ())? + private let b64: String? + private let bytes: [UInt8]? + + internal init( + request: Request, + urlRequest: URLRequest, + publicKey: String, + responseType: R.Type, + timeout: TimeInterval + ) where R: Decodable { + self.request = urlRequest + self.server = request.server + self.publicKey = publicKey + self.originalType = responseType + self.responseType = responseType + self.timeout = timeout + self.responseConverter = { _, response in + guard let validResponse: R = response as? R else { throw HTTPError.invalidResponse } + + return validResponse + } + + // The following data is needed in this type for handling batch requests + self.method = request.method + self.endpoint = request.endpoint + self.path = request.urlPathAndParamsString + self.batchEndpoints = ((request.body as? BatchRequest)? + .requests + .map { $0.request.endpoint }) + .defaulting(to: []) + self.batchResponseTypes = ((request.body as? BatchRequest)? + .requests + .flatMap { $0.request.batchResponseTypes }) + .defaulting(to: [HTTP.BatchSubResponse.self]) + + // 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 + } + } + + private init( + request: URLRequest, + server: String, + publicKey: String, + originalType: U.Type, + responseType: R.Type, + timeout: TimeInterval, + responseConverter: @escaping (ResponseInfoType, Any) throws -> R, + method: HTTPMethod, + endpoint: Endpoint, + path: String, + batchEndpoints: [Endpoint], + batchResponseTypes: [Decodable.Type], + jsonBodyEncoder: ((inout KeyedEncodingContainer, BatchRequest.Child.CodingKeys) throws -> ())?, + b64: String?, + bytes: [UInt8]? + ) { + self.request = request + self.server = server + self.publicKey = publicKey + self.originalType = originalType + self.responseType = responseType + self.timeout = timeout + self.responseConverter = responseConverter + + // The following data is needed in this type for handling batch requests + self.method = method + self.endpoint = endpoint + self.path = path + self.batchEndpoints = batchEndpoints + self.batchResponseTypes = batchResponseTypes + self.jsonBodyEncoder = jsonBodyEncoder + self.b64 = b64 + self.bytes = bytes + } + + // MARK: - ErasedPreparedSendData + + public func encodeForBatchRequest(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: BatchRequest.Child.CodingKeys.self) + + // Exclude request signature headers (not used for sub-requests) + let batchRequestHeaders: [String: String] = (request.allHTTPHeaderFields ?? [:]) + .filter { key, _ in + key.lowercased() != HTTPHeader.sogsPubKey.lowercased() && + key.lowercased() != HTTPHeader.sogsTimestamp.lowercased() && + key.lowercased() != HTTPHeader.sogsNonce.lowercased() && + key.lowercased() != HTTPHeader.sogsSignature.lowercased() + } + + if !batchRequestHeaders.isEmpty { + try container.encode(batchRequestHeaders, forKey: .headers) + } + + try container.encode(method, forKey: .method) + try container.encode(path, forKey: .path) + try jsonBodyEncoder?(&container, .json) + try container.encodeIfPresent(b64, forKey: .b64) + try container.encodeIfPresent(bytes, forKey: .bytes) + } + } +} + +public extension OpenGroupAPI.PreparedSendData { + func map(transform: @escaping (ResponseInfoType, R) throws -> O) -> OpenGroupAPI.PreparedSendData { + return OpenGroupAPI.PreparedSendData( + request: request, + server: server, + publicKey: publicKey, + originalType: originalType, + responseType: O.self, + timeout: timeout, + responseConverter: { info, response in + let validResponse: R = try responseConverter(info, response) + + return try transform(info, validResponse) + }, + method: method, + endpoint: endpoint, + path: path, + batchEndpoints: batchEndpoints, + batchResponseTypes: batchResponseTypes, + jsonBodyEncoder: jsonBodyEncoder, + b64: b64, + bytes: bytes + ) + } +} + +// MARK: - Convenience + +public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error { + func decoded( + with preparedData: OpenGroupAPI.PreparedSendData, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher<(ResponseInfoType, R), Error> { + self + .tryMap { responseInfo, maybeData -> (ResponseInfoType, R) in + // Depending on the 'originalType' we need to process the response differently + let targetData: Any = try { + switch preparedData.originalType { + case is OpenGroupAPI.BatchResponse.Type: + let responses: [Decodable] = try HTTP.BatchResponse.decodingResponses( + from: maybeData, + as: preparedData.batchResponseTypes, + requireAllResults: true, + using: dependencies + ) + + return OpenGroupAPI.BatchResponse( + info: responseInfo, + data: Swift.zip(preparedData.batchEndpoints, responses) + .reduce(into: [:]) { result, next in + result[next.0] = next.1 + } + ) + + case is NoResponse.Type: return NoResponse() + case is Optional.Type: return maybeData as Any + case is Data.Type: return try maybeData ?? { throw HTTPError.parsingFailed }() + + case is _OptionalProtocol.Type: + guard let data: Data = maybeData else { return maybeData as Any } + + return try preparedData.originalType.decoded(from: data, using: dependencies) + + default: + guard let data: Data = maybeData else { throw HTTPError.parsingFailed } + + return try preparedData.originalType.decoded(from: data, using: dependencies) + } + }() + + // Generate and return the converted data + let convertedData: R = try preparedData.responseConverter(responseInfo, targetData) + + return (responseInfo, convertedData) + } + .eraseToAnyPublisher() + } +} + +// MARK: - _OptionalProtocol + +/// This protocol should only be used within this file and is used to distinguish between `Any.Type` and `Optional.Type` as +/// it seems that `is Optional.Type` doesn't work nicely but this protocol works nicely as long as the case is under any explicit +/// `Optional` handling that we need +private protocol _OptionalProtocol {} + +extension Optional: _OptionalProtocol {} diff --git a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift index 60c148595..483d2f253 100644 --- a/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift +++ b/SessionMessagingKit/Open Groups/Types/SOGSEndpoint.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit extension OpenGroupAPI { public enum Endpoint: EndpointType { @@ -58,7 +59,7 @@ extension OpenGroupAPI { case userUnban(String) case userModerator(String) - var path: String { + public var path: String { switch self { // Utility diff --git a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift index 3e3842f4d..223a42e44 100644 --- a/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift +++ b/SessionMessagingKit/Open Groups/Types/SodiumProtocols.swift @@ -3,6 +3,7 @@ import Foundation import Sodium import Curve25519Kit +import SessionUtilitiesKit public protocol SodiumType { func getBox() -> BoxType @@ -11,7 +12,7 @@ public protocol SodiumType { func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? - func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair, genericHash: GenericHashType) -> Box.KeyPair? + func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair, genericHash: GenericHashType) -> KeyPair? func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? func combineKeys(lhsKeyBytes: Bytes, rhsKeyBytes: Bytes) -> Bytes? @@ -29,7 +30,7 @@ public protocol AeadXChaCha20Poly1305IetfType { } public protocol Ed25519Type { - func sign(data: Bytes, keyPair: Box.KeyPair) throws -> Bytes? + func sign(data: Bytes, keyPair: KeyPair) throws -> Bytes? func verifySignature(_ signature: Data, publicKey: Data, data: Data) throws -> Bool } @@ -81,7 +82,7 @@ extension Sodium: SodiumType { public func getSign() -> SignType { return sign } public func getAeadXChaCha20Poly1305Ietf() -> AeadXChaCha20Poly1305IetfType { return aead.xchacha20poly1305ietf } - public func blindedKeyPair(serverPublicKey: String, edKeyPair: Box.KeyPair) -> Box.KeyPair? { + public func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair) -> KeyPair? { return blindedKeyPair(serverPublicKey: serverPublicKey, edKeyPair: edKeyPair, genericHash: getGenericHash()) } } @@ -92,7 +93,7 @@ extension Sign: SignType {} extension Aead.XChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType {} struct Ed25519Wrapper: Ed25519Type { - func sign(data: Bytes, keyPair: Box.KeyPair) throws -> Bytes? { + func sign(data: Bytes, keyPair: KeyPair) throws -> Bytes? { let ecKeyPair: ECKeyPair = try ECKeyPair( publicKeyData: Data(keyPair.publicKey), privateKeyData: Data(keyPair.secretKey) diff --git a/SessionMessagingKit/Protos/Generated/SNProto.swift b/SessionMessagingKit/Protos/Generated/SNProto.swift index 22a8dd6b2..ed766ca8d 100644 --- a/SessionMessagingKit/Protos/Generated/SNProto.swift +++ b/SessionMessagingKit/Protos/Generated/SNProto.swift @@ -618,6 +618,9 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { if let _value = messageRequestResponse { builder.setMessageRequestResponse(_value) } + if let _value = sharedConfigMessage { + builder.setSharedConfigMessage(_value) + } return builder } @@ -659,6 +662,10 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { proto.messageRequestResponse = valueParam.proto } + @objc public func setSharedConfigMessage(_ valueParam: SNProtoSharedConfigMessage) { + proto.sharedConfigMessage = valueParam.proto + } + @objc public func build() throws -> SNProtoContent { return try SNProtoContent.parseProto(proto) } @@ -686,6 +693,8 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { @objc public let messageRequestResponse: SNProtoMessageRequestResponse? + @objc public let sharedConfigMessage: SNProtoSharedConfigMessage? + private init(proto: SessionProtos_Content, dataMessage: SNProtoDataMessage?, callMessage: SNProtoCallMessage?, @@ -694,7 +703,8 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { configurationMessage: SNProtoConfigurationMessage?, dataExtractionNotification: SNProtoDataExtractionNotification?, unsendRequest: SNProtoUnsendRequest?, - messageRequestResponse: SNProtoMessageRequestResponse?) { + messageRequestResponse: SNProtoMessageRequestResponse?, + sharedConfigMessage: SNProtoSharedConfigMessage?) { self.proto = proto self.dataMessage = dataMessage self.callMessage = callMessage @@ -704,6 +714,7 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { self.dataExtractionNotification = dataExtractionNotification self.unsendRequest = unsendRequest self.messageRequestResponse = messageRequestResponse + self.sharedConfigMessage = sharedConfigMessage } @objc @@ -757,6 +768,11 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { messageRequestResponse = try SNProtoMessageRequestResponse.parseProto(proto.messageRequestResponse) } + var sharedConfigMessage: SNProtoSharedConfigMessage? = nil + if proto.hasSharedConfigMessage { + sharedConfigMessage = try SNProtoSharedConfigMessage.parseProto(proto.sharedConfigMessage) + } + // MARK: - Begin Validation Logic for SNProtoContent - // MARK: - End Validation Logic for SNProtoContent - @@ -769,7 +785,8 @@ extension SNProtoMessageRequestResponse.SNProtoMessageRequestResponseBuilder { configurationMessage: configurationMessage, dataExtractionNotification: dataExtractionNotification, unsendRequest: unsendRequest, - messageRequestResponse: messageRequestResponse) + messageRequestResponse: messageRequestResponse, + sharedConfigMessage: sharedConfigMessage) return result } @@ -2449,9 +2466,6 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr builder.setBody(_value) } builder.setAttachments(attachments) - if let _value = group { - builder.setGroup(_value) - } if hasFlags { builder.setFlags(flags) } @@ -2506,10 +2520,6 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr proto.attachments = wrappedItems.map { $0.proto } } - @objc public func setGroup(_ valueParam: SNProtoGroupContext) { - proto.group = valueParam.proto - } - @objc public func setFlags(_ valueParam: UInt32) { proto.flags = valueParam } @@ -2573,8 +2583,6 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr @objc public let attachments: [SNProtoAttachmentPointer] - @objc public let group: SNProtoGroupContext? - @objc public let quote: SNProtoDataMessageQuote? @objc public let preview: [SNProtoDataMessagePreview] @@ -2640,7 +2648,6 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr private init(proto: SessionProtos_DataMessage, attachments: [SNProtoAttachmentPointer], - group: SNProtoGroupContext?, quote: SNProtoDataMessageQuote?, preview: [SNProtoDataMessagePreview], reaction: SNProtoDataMessageReaction?, @@ -2649,7 +2656,6 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr closedGroupControlMessage: SNProtoDataMessageClosedGroupControlMessage?) { self.proto = proto self.attachments = attachments - self.group = group self.quote = quote self.preview = preview self.reaction = reaction @@ -2672,11 +2678,6 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr var attachments: [SNProtoAttachmentPointer] = [] attachments = try proto.attachments.map { try SNProtoAttachmentPointer.parseProto($0) } - var group: SNProtoGroupContext? = nil - if proto.hasGroup { - group = try SNProtoGroupContext.parseProto(proto.group) - } - var quote: SNProtoDataMessageQuote? = nil if proto.hasQuote { quote = try SNProtoDataMessageQuote.parseProto(proto.quote) @@ -2711,7 +2712,6 @@ extension SNProtoDataMessageClosedGroupControlMessage.SNProtoDataMessageClosedGr let result = SNProtoDataMessage(proto: proto, attachments: attachments, - group: group, quote: quote, preview: preview, reaction: reaction, @@ -3706,152 +3706,100 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder { #endif -// MARK: - SNProtoGroupContext +// MARK: - SNProtoSharedConfigMessage -@objc public class SNProtoGroupContext: NSObject { +@objc public class SNProtoSharedConfigMessage: NSObject { - // MARK: - SNProtoGroupContextType + // MARK: - SNProtoSharedConfigMessageKind - @objc public enum SNProtoGroupContextType: Int32 { - case unknown = 0 - case update = 1 - case deliver = 2 - case quit = 3 - case requestInfo = 4 + @objc public enum SNProtoSharedConfigMessageKind: Int32 { + case userProfile = 1 + case contacts = 2 + case convoInfoVolatile = 3 + case userGroups = 4 } - private class func SNProtoGroupContextTypeWrap(_ value: SessionProtos_GroupContext.TypeEnum) -> SNProtoGroupContextType { + private class func SNProtoSharedConfigMessageKindWrap(_ value: SessionProtos_SharedConfigMessage.Kind) -> SNProtoSharedConfigMessageKind { switch value { - case .unknown: return .unknown - case .update: return .update - case .deliver: return .deliver - case .quit: return .quit - case .requestInfo: return .requestInfo + case .userProfile: return .userProfile + case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .userGroups: return .userGroups } } - private class func SNProtoGroupContextTypeUnwrap(_ value: SNProtoGroupContextType) -> SessionProtos_GroupContext.TypeEnum { + private class func SNProtoSharedConfigMessageKindUnwrap(_ value: SNProtoSharedConfigMessageKind) -> SessionProtos_SharedConfigMessage.Kind { switch value { - case .unknown: return .unknown - case .update: return .update - case .deliver: return .deliver - case .quit: return .quit - case .requestInfo: return .requestInfo + case .userProfile: return .userProfile + case .contacts: return .contacts + case .convoInfoVolatile: return .convoInfoVolatile + case .userGroups: return .userGroups } } - // MARK: - SNProtoGroupContextBuilder + // MARK: - SNProtoSharedConfigMessageBuilder - @objc public class func builder(id: Data, type: SNProtoGroupContextType) -> SNProtoGroupContextBuilder { - return SNProtoGroupContextBuilder(id: id, type: type) + @objc public class func builder(kind: SNProtoSharedConfigMessageKind, seqno: Int64, data: Data) -> SNProtoSharedConfigMessageBuilder { + return SNProtoSharedConfigMessageBuilder(kind: kind, seqno: seqno, data: data) } // asBuilder() constructs a builder that reflects the proto's contents. - @objc public func asBuilder() -> SNProtoGroupContextBuilder { - let builder = SNProtoGroupContextBuilder(id: id, type: type) - if let _value = name { - builder.setName(_value) - } - builder.setMembers(members) - if let _value = avatar { - builder.setAvatar(_value) - } - builder.setAdmins(admins) + @objc public func asBuilder() -> SNProtoSharedConfigMessageBuilder { + let builder = SNProtoSharedConfigMessageBuilder(kind: kind, seqno: seqno, data: data) return builder } - @objc public class SNProtoGroupContextBuilder: NSObject { + @objc public class SNProtoSharedConfigMessageBuilder: NSObject { - private var proto = SessionProtos_GroupContext() + private var proto = SessionProtos_SharedConfigMessage() @objc fileprivate override init() {} - @objc fileprivate init(id: Data, type: SNProtoGroupContextType) { + @objc fileprivate init(kind: SNProtoSharedConfigMessageKind, seqno: Int64, data: Data) { super.init() - setId(id) - setType(type) + setKind(kind) + setSeqno(seqno) + setData(data) } - @objc public func setId(_ valueParam: Data) { - proto.id = valueParam + @objc public func setKind(_ valueParam: SNProtoSharedConfigMessageKind) { + proto.kind = SNProtoSharedConfigMessageKindUnwrap(valueParam) } - @objc public func setType(_ valueParam: SNProtoGroupContextType) { - proto.type = SNProtoGroupContextTypeUnwrap(valueParam) + @objc public func setSeqno(_ valueParam: Int64) { + proto.seqno = valueParam } - @objc public func setName(_ valueParam: String) { - proto.name = valueParam + @objc public func setData(_ valueParam: Data) { + proto.data = valueParam } - @objc public func addMembers(_ valueParam: String) { - var items = proto.members - items.append(valueParam) - proto.members = items - } - - @objc public func setMembers(_ wrappedItems: [String]) { - proto.members = wrappedItems - } - - @objc public func setAvatar(_ valueParam: SNProtoAttachmentPointer) { - proto.avatar = valueParam.proto - } - - @objc public func addAdmins(_ valueParam: String) { - var items = proto.admins - items.append(valueParam) - proto.admins = items - } - - @objc public func setAdmins(_ wrappedItems: [String]) { - proto.admins = wrappedItems - } - - @objc public func build() throws -> SNProtoGroupContext { - return try SNProtoGroupContext.parseProto(proto) + @objc public func build() throws -> SNProtoSharedConfigMessage { + return try SNProtoSharedConfigMessage.parseProto(proto) } @objc public func buildSerializedData() throws -> Data { - return try SNProtoGroupContext.parseProto(proto).serializedData() + return try SNProtoSharedConfigMessage.parseProto(proto).serializedData() } } - fileprivate let proto: SessionProtos_GroupContext + fileprivate let proto: SessionProtos_SharedConfigMessage - @objc public let id: Data + @objc public let kind: SNProtoSharedConfigMessageKind - @objc public let type: SNProtoGroupContextType + @objc public let seqno: Int64 - @objc public let avatar: SNProtoAttachmentPointer? + @objc public let data: Data - @objc public var name: String? { - guard proto.hasName else { - return nil - } - return proto.name - } - @objc public var hasName: Bool { - return proto.hasName - } - - @objc public var members: [String] { - return proto.members - } - - @objc public var admins: [String] { - return proto.admins - } - - private init(proto: SessionProtos_GroupContext, - id: Data, - type: SNProtoGroupContextType, - avatar: SNProtoAttachmentPointer?) { + private init(proto: SessionProtos_SharedConfigMessage, + kind: SNProtoSharedConfigMessageKind, + seqno: Int64, + data: Data) { self.proto = proto - self.id = id - self.type = type - self.avatar = avatar + self.kind = kind + self.seqno = seqno + self.data = data } @objc @@ -3859,35 +3807,35 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder { return try self.proto.serializedData() } - @objc public class func parseData(_ serializedData: Data) throws -> SNProtoGroupContext { - let proto = try SessionProtos_GroupContext(serializedData: serializedData) + @objc public class func parseData(_ serializedData: Data) throws -> SNProtoSharedConfigMessage { + let proto = try SessionProtos_SharedConfigMessage(serializedData: serializedData) return try parseProto(proto) } - fileprivate class func parseProto(_ proto: SessionProtos_GroupContext) throws -> SNProtoGroupContext { - guard proto.hasID else { - throw SNProtoError.invalidProtobuf(description: "\(logTag) missing required field: id") + fileprivate class func parseProto(_ proto: SessionProtos_SharedConfigMessage) throws -> SNProtoSharedConfigMessage { + guard proto.hasKind else { + throw SNProtoError.invalidProtobuf(description: "\(logTag) missing required field: kind") } - let id = proto.id + let kind = SNProtoSharedConfigMessageKindWrap(proto.kind) - guard proto.hasType else { - throw SNProtoError.invalidProtobuf(description: "\(logTag) missing required field: type") + guard proto.hasSeqno else { + throw SNProtoError.invalidProtobuf(description: "\(logTag) missing required field: seqno") } - let type = SNProtoGroupContextTypeWrap(proto.type) + let seqno = proto.seqno - var avatar: SNProtoAttachmentPointer? = nil - if proto.hasAvatar { - avatar = try SNProtoAttachmentPointer.parseProto(proto.avatar) + guard proto.hasData else { + throw SNProtoError.invalidProtobuf(description: "\(logTag) missing required field: data") } + let data = proto.data - // MARK: - Begin Validation Logic for SNProtoGroupContext - + // MARK: - Begin Validation Logic for SNProtoSharedConfigMessage - - // MARK: - End Validation Logic for SNProtoGroupContext - + // MARK: - End Validation Logic for SNProtoSharedConfigMessage - - let result = SNProtoGroupContext(proto: proto, - id: id, - type: type, - avatar: avatar) + let result = SNProtoSharedConfigMessage(proto: proto, + kind: kind, + seqno: seqno, + data: data) return result } @@ -3898,14 +3846,14 @@ extension SNProtoAttachmentPointer.SNProtoAttachmentPointerBuilder { #if DEBUG -extension SNProtoGroupContext { +extension SNProtoSharedConfigMessage { @objc public func serializedDataIgnoringErrors() -> Data? { return try! self.serializedData() } } -extension SNProtoGroupContext.SNProtoGroupContextBuilder { - @objc public func buildIgnoringErrors() -> SNProtoGroupContext? { +extension SNProtoSharedConfigMessage.SNProtoSharedConfigMessageBuilder { + @objc public func buildIgnoringErrors() -> SNProtoSharedConfigMessage? { return try! self.build() } } diff --git a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift index 488fc61f9..6f209cb67 100644 --- a/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift +++ b/SessionMessagingKit/Protos/Generated/SessionProtos.pb.swift @@ -348,6 +348,15 @@ struct SessionProtos_Content { /// Clears the value of `messageRequestResponse`. Subsequent reads from it will return its default value. mutating func clearMessageRequestResponse() {_uniqueStorage()._messageRequestResponse = nil} + var sharedConfigMessage: SessionProtos_SharedConfigMessage { + get {return _storage._sharedConfigMessage ?? SessionProtos_SharedConfigMessage()} + set {_uniqueStorage()._sharedConfigMessage = newValue} + } + /// Returns true if `sharedConfigMessage` has been explicitly set. + var hasSharedConfigMessage: Bool {return _storage._sharedConfigMessage != nil} + /// Clears the value of `sharedConfigMessage`. Subsequent reads from it will return its default value. + mutating func clearSharedConfigMessage() {_uniqueStorage()._sharedConfigMessage = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -591,15 +600,7 @@ struct SessionProtos_DataMessage { set {_uniqueStorage()._attachments = newValue} } - var group: SessionProtos_GroupContext { - get {return _storage._group ?? SessionProtos_GroupContext()} - set {_uniqueStorage()._group = newValue} - } - /// Returns true if `group` has been explicitly set. - var hasGroup: Bool {return _storage._group != nil} - /// Clears the value of `group`. Subsequent reads from it will return its default value. - mutating func clearGroup() {_uniqueStorage()._group = nil} - + /// optional GroupContext group = 3; // No longer used var flags: UInt32 { get {return _storage._flags ?? 0} set {_uniqueStorage()._flags = newValue} @@ -1580,91 +1581,70 @@ extension SessionProtos_AttachmentPointer.Flags: CaseIterable { #endif // swift(>=4.2) -struct SessionProtos_GroupContext { +struct SessionProtos_SharedConfigMessage { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. /// @required - var id: Data { - get {return _storage._id ?? Data()} - set {_uniqueStorage()._id = newValue} + var kind: SessionProtos_SharedConfigMessage.Kind { + get {return _kind ?? .userProfile} + set {_kind = newValue} } - /// Returns true if `id` has been explicitly set. - var hasID: Bool {return _storage._id != nil} - /// Clears the value of `id`. Subsequent reads from it will return its default value. - mutating func clearID() {_uniqueStorage()._id = nil} + /// Returns true if `kind` has been explicitly set. + var hasKind: Bool {return self._kind != nil} + /// Clears the value of `kind`. Subsequent reads from it will return its default value. + mutating func clearKind() {self._kind = nil} /// @required - var type: SessionProtos_GroupContext.TypeEnum { - get {return _storage._type ?? .unknown} - set {_uniqueStorage()._type = newValue} + var seqno: Int64 { + get {return _seqno ?? 0} + set {_seqno = newValue} } - /// Returns true if `type` has been explicitly set. - var hasType: Bool {return _storage._type != nil} - /// Clears the value of `type`. Subsequent reads from it will return its default value. - mutating func clearType() {_uniqueStorage()._type = nil} + /// Returns true if `seqno` has been explicitly set. + var hasSeqno: Bool {return self._seqno != nil} + /// Clears the value of `seqno`. Subsequent reads from it will return its default value. + mutating func clearSeqno() {self._seqno = nil} - var name: String { - get {return _storage._name ?? String()} - set {_uniqueStorage()._name = newValue} - } - /// Returns true if `name` has been explicitly set. - var hasName: Bool {return _storage._name != nil} - /// Clears the value of `name`. Subsequent reads from it will return its default value. - mutating func clearName() {_uniqueStorage()._name = nil} - - var members: [String] { - get {return _storage._members} - set {_uniqueStorage()._members = newValue} - } - - var avatar: SessionProtos_AttachmentPointer { - get {return _storage._avatar ?? SessionProtos_AttachmentPointer()} - set {_uniqueStorage()._avatar = newValue} - } - /// Returns true if `avatar` has been explicitly set. - var hasAvatar: Bool {return _storage._avatar != nil} - /// Clears the value of `avatar`. Subsequent reads from it will return its default value. - mutating func clearAvatar() {_uniqueStorage()._avatar = nil} - - var admins: [String] { - get {return _storage._admins} - set {_uniqueStorage()._admins = newValue} + /// @required + var data: Data { + get {return _data ?? Data()} + set {_data = newValue} } + /// Returns true if `data` has been explicitly set. + var hasData: Bool {return self._data != nil} + /// Clears the value of `data`. Subsequent reads from it will return its default value. + mutating func clearData() {self._data = nil} var unknownFields = SwiftProtobuf.UnknownStorage() - enum TypeEnum: SwiftProtobuf.Enum { + enum Kind: SwiftProtobuf.Enum { typealias RawValue = Int - case unknown // = 0 - case update // = 1 - case deliver // = 2 - case quit // = 3 - case requestInfo // = 4 + case userProfile // = 1 + case contacts // = 2 + case convoInfoVolatile // = 3 + case userGroups // = 4 init() { - self = .unknown + self = .userProfile } init?(rawValue: Int) { switch rawValue { - case 0: self = .unknown - case 1: self = .update - case 2: self = .deliver - case 3: self = .quit - case 4: self = .requestInfo + case 1: self = .userProfile + case 2: self = .contacts + case 3: self = .convoInfoVolatile + case 4: self = .userGroups default: return nil } } var rawValue: Int { switch self { - case .unknown: return 0 - case .update: return 1 - case .deliver: return 2 - case .quit: return 3 - case .requestInfo: return 4 + case .userProfile: return 1 + case .contacts: return 2 + case .convoInfoVolatile: return 3 + case .userGroups: return 4 } } @@ -1672,12 +1652,14 @@ struct SessionProtos_GroupContext { init() {} - fileprivate var _storage = _StorageClass.defaultInstance + fileprivate var _kind: SessionProtos_SharedConfigMessage.Kind? = nil + fileprivate var _seqno: Int64? = nil + fileprivate var _data: Data? = nil } #if swift(>=4.2) -extension SessionProtos_GroupContext.TypeEnum: CaseIterable { +extension SessionProtos_SharedConfigMessage.Kind: CaseIterable { // Support synthesized by the compiler. } @@ -1933,6 +1915,7 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm 8: .same(proto: "dataExtractionNotification"), 9: .same(proto: "unsendRequest"), 10: .same(proto: "messageRequestResponse"), + 11: .same(proto: "sharedConfigMessage"), ] fileprivate class _StorageClass { @@ -1944,6 +1927,7 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm var _dataExtractionNotification: SessionProtos_DataExtractionNotification? = nil var _unsendRequest: SessionProtos_UnsendRequest? = nil var _messageRequestResponse: SessionProtos_MessageRequestResponse? = nil + var _sharedConfigMessage: SessionProtos_SharedConfigMessage? = nil static let defaultInstance = _StorageClass() @@ -1958,6 +1942,7 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm _dataExtractionNotification = source._dataExtractionNotification _unsendRequest = source._unsendRequest _messageRequestResponse = source._messageRequestResponse + _sharedConfigMessage = source._sharedConfigMessage } } @@ -1978,6 +1963,7 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm if let v = _storage._dataExtractionNotification, !v.isInitialized {return false} if let v = _storage._unsendRequest, !v.isInitialized {return false} if let v = _storage._messageRequestResponse, !v.isInitialized {return false} + if let v = _storage._sharedConfigMessage, !v.isInitialized {return false} return true } } @@ -1998,6 +1984,7 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm case 8: try { try decoder.decodeSingularMessageField(value: &_storage._dataExtractionNotification) }() case 9: try { try decoder.decodeSingularMessageField(value: &_storage._unsendRequest) }() case 10: try { try decoder.decodeSingularMessageField(value: &_storage._messageRequestResponse) }() + case 11: try { try decoder.decodeSingularMessageField(value: &_storage._sharedConfigMessage) }() default: break } } @@ -2034,6 +2021,9 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm try { if let v = _storage._messageRequestResponse { try visitor.visitSingularMessageField(value: v, fieldNumber: 10) } }() + try { if let v = _storage._sharedConfigMessage { + try visitor.visitSingularMessageField(value: v, fieldNumber: 11) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -2051,6 +2041,7 @@ extension SessionProtos_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageIm if _storage._dataExtractionNotification != rhs_storage._dataExtractionNotification {return false} if _storage._unsendRequest != rhs_storage._unsendRequest {return false} if _storage._messageRequestResponse != rhs_storage._messageRequestResponse {return false} + if _storage._sharedConfigMessage != rhs_storage._sharedConfigMessage {return false} return true } if !storagesAreEqual {return false} @@ -2286,7 +2277,6 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 1: .same(proto: "body"), 2: .same(proto: "attachments"), - 3: .same(proto: "group"), 4: .same(proto: "flags"), 5: .same(proto: "expireTimer"), 6: .same(proto: "profileKey"), @@ -2303,7 +2293,6 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa fileprivate class _StorageClass { var _body: String? = nil var _attachments: [SessionProtos_AttachmentPointer] = [] - var _group: SessionProtos_GroupContext? = nil var _flags: UInt32? = nil var _expireTimer: UInt32? = nil var _profileKey: Data? = nil @@ -2323,7 +2312,6 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa init(copying source: _StorageClass) { _body = source._body _attachments = source._attachments - _group = source._group _flags = source._flags _expireTimer = source._expireTimer _profileKey = source._profileKey @@ -2348,7 +2336,6 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa public var isInitialized: Bool { return withExtendedLifetime(_storage) { (_storage: _StorageClass) in if !SwiftProtobuf.Internal.areAllInitialized(_storage._attachments) {return false} - if let v = _storage._group, !v.isInitialized {return false} if let v = _storage._quote, !v.isInitialized {return false} if !SwiftProtobuf.Internal.areAllInitialized(_storage._preview) {return false} if let v = _storage._reaction, !v.isInitialized {return false} @@ -2368,7 +2355,6 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &_storage._body) }() case 2: try { try decoder.decodeRepeatedMessageField(value: &_storage._attachments) }() - case 3: try { try decoder.decodeSingularMessageField(value: &_storage._group) }() case 4: try { try decoder.decodeSingularUInt32Field(value: &_storage._flags) }() case 5: try { try decoder.decodeSingularUInt32Field(value: &_storage._expireTimer) }() case 6: try { try decoder.decodeSingularBytesField(value: &_storage._profileKey) }() @@ -2398,9 +2384,6 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa if !_storage._attachments.isEmpty { try visitor.visitRepeatedMessageField(value: _storage._attachments, fieldNumber: 2) } - try { if let v = _storage._group { - try visitor.visitSingularMessageField(value: v, fieldNumber: 3) - } }() try { if let v = _storage._flags { try visitor.visitSingularUInt32Field(value: v, fieldNumber: 4) } }() @@ -2445,7 +2428,6 @@ extension SessionProtos_DataMessage: SwiftProtobuf.Message, SwiftProtobuf._Messa let rhs_storage = _args.1 if _storage._body != rhs_storage._body {return false} if _storage._attachments != rhs_storage._attachments {return false} - if _storage._group != rhs_storage._group {return false} if _storage._flags != rhs_storage._flags {return false} if _storage._expireTimer != rhs_storage._expireTimer {return false} if _storage._profileKey != rhs_storage._profileKey {return false} @@ -3301,127 +3283,66 @@ extension SessionProtos_AttachmentPointer.Flags: SwiftProtobuf._ProtoNameProvidi ] } -extension SessionProtos_GroupContext: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { - static let protoMessageName: String = _protobuf_package + ".GroupContext" +extension SessionProtos_SharedConfigMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".SharedConfigMessage" static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 1: .same(proto: "id"), - 2: .same(proto: "type"), - 3: .same(proto: "name"), - 4: .same(proto: "members"), - 5: .same(proto: "avatar"), - 6: .same(proto: "admins"), + 1: .same(proto: "kind"), + 2: .same(proto: "seqno"), + 3: .same(proto: "data"), ] - fileprivate class _StorageClass { - var _id: Data? = nil - var _type: SessionProtos_GroupContext.TypeEnum? = nil - var _name: String? = nil - var _members: [String] = [] - var _avatar: SessionProtos_AttachmentPointer? = nil - var _admins: [String] = [] - - static let defaultInstance = _StorageClass() - - private init() {} - - init(copying source: _StorageClass) { - _id = source._id - _type = source._type - _name = source._name - _members = source._members - _avatar = source._avatar - _admins = source._admins - } - } - - fileprivate mutating func _uniqueStorage() -> _StorageClass { - if !isKnownUniquelyReferenced(&_storage) { - _storage = _StorageClass(copying: _storage) - } - return _storage - } - public var isInitialized: Bool { - return withExtendedLifetime(_storage) { (_storage: _StorageClass) in - if let v = _storage._avatar, !v.isInitialized {return false} - return true - } + if self._kind == nil {return false} + if self._seqno == nil {return false} + if self._data == nil {return false} + return true } mutating func decodeMessage(decoder: inout D) throws { - _ = _uniqueStorage() - try withExtendedLifetime(_storage) { (_storage: _StorageClass) in - while let fieldNumber = try decoder.nextFieldNumber() { - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case branch when no optimizations are - // enabled. https://github.com/apple/swift-protobuf/issues/1034 - switch fieldNumber { - case 1: try { try decoder.decodeSingularBytesField(value: &_storage._id) }() - case 2: try { try decoder.decodeSingularEnumField(value: &_storage._type) }() - case 3: try { try decoder.decodeSingularStringField(value: &_storage._name) }() - case 4: try { try decoder.decodeRepeatedStringField(value: &_storage._members) }() - case 5: try { try decoder.decodeSingularMessageField(value: &_storage._avatar) }() - case 6: try { try decoder.decodeRepeatedStringField(value: &_storage._admins) }() - default: break - } + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self._kind) }() + case 2: try { try decoder.decodeSingularInt64Field(value: &self._seqno) }() + case 3: try { try decoder.decodeSingularBytesField(value: &self._data) }() + default: break } } } func traverse(visitor: inout V) throws { - try withExtendedLifetime(_storage) { (_storage: _StorageClass) in - // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every if/case branch local when no optimizations - // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and - // https://github.com/apple/swift-protobuf/issues/1182 - try { if let v = _storage._id { - try visitor.visitSingularBytesField(value: v, fieldNumber: 1) - } }() - try { if let v = _storage._type { - try visitor.visitSingularEnumField(value: v, fieldNumber: 2) - } }() - try { if let v = _storage._name { - try visitor.visitSingularStringField(value: v, fieldNumber: 3) - } }() - if !_storage._members.isEmpty { - try visitor.visitRepeatedStringField(value: _storage._members, fieldNumber: 4) - } - try { if let v = _storage._avatar { - try visitor.visitSingularMessageField(value: v, fieldNumber: 5) - } }() - if !_storage._admins.isEmpty { - try visitor.visitRepeatedStringField(value: _storage._admins, fieldNumber: 6) - } - } + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._kind { + try visitor.visitSingularEnumField(value: v, fieldNumber: 1) + } }() + try { if let v = self._seqno { + try visitor.visitSingularInt64Field(value: v, fieldNumber: 2) + } }() + try { if let v = self._data { + try visitor.visitSingularBytesField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } - static func ==(lhs: SessionProtos_GroupContext, rhs: SessionProtos_GroupContext) -> Bool { - if lhs._storage !== rhs._storage { - let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in - let _storage = _args.0 - let rhs_storage = _args.1 - if _storage._id != rhs_storage._id {return false} - if _storage._type != rhs_storage._type {return false} - if _storage._name != rhs_storage._name {return false} - if _storage._members != rhs_storage._members {return false} - if _storage._avatar != rhs_storage._avatar {return false} - if _storage._admins != rhs_storage._admins {return false} - return true - } - if !storagesAreEqual {return false} - } + static func ==(lhs: SessionProtos_SharedConfigMessage, rhs: SessionProtos_SharedConfigMessage) -> Bool { + if lhs._kind != rhs._kind {return false} + if lhs._seqno != rhs._seqno {return false} + if lhs._data != rhs._data {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } -extension SessionProtos_GroupContext.TypeEnum: SwiftProtobuf._ProtoNameProviding { +extension SessionProtos_SharedConfigMessage.Kind: SwiftProtobuf._ProtoNameProviding { static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ - 0: .same(proto: "UNKNOWN"), - 1: .same(proto: "UPDATE"), - 2: .same(proto: "DELIVER"), - 3: .same(proto: "QUIT"), - 4: .same(proto: "REQUEST_INFO"), + 1: .same(proto: "USER_PROFILE"), + 2: .same(proto: "CONTACTS"), + 3: .same(proto: "CONVO_INFO_VOLATILE"), + 4: .same(proto: "USER_GROUPS"), ] } diff --git a/SessionMessagingKit/Protos/SessionProtos.proto b/SessionMessagingKit/Protos/SessionProtos.proto index b642c5270..429c10b14 100644 --- a/SessionMessagingKit/Protos/SessionProtos.proto +++ b/SessionMessagingKit/Protos/SessionProtos.proto @@ -57,6 +57,7 @@ message Content { optional DataExtractionNotification dataExtractionNotification = 8; optional UnsendRequest unsendRequest = 9; optional MessageRequestResponse messageRequestResponse = 10; + optional SharedConfigMessage sharedConfigMessage = 11; } message CallMessage { @@ -193,7 +194,7 @@ message DataMessage { optional string body = 1; repeated AttachmentPointer attachments = 2; - optional GroupContext group = 3; + // optional GroupContext group = 3; // No longer used optional uint32 flags = 4; optional uint32 expireTimer = 5; optional bytes profileKey = 6; @@ -271,22 +272,18 @@ message AttachmentPointer { optional string url = 101; } -message GroupContext { +message SharedConfigMessage { + enum Kind { + USER_PROFILE = 1; + CONTACTS = 2; + CONVO_INFO_VOLATILE = 3; + USER_GROUPS = 4; + } - enum Type { - UNKNOWN = 0; - UPDATE = 1; - DELIVER = 2; - QUIT = 3; - REQUEST_INFO = 4; - } - - // @required - optional bytes id = 1; - // @required - optional Type type = 2; - optional string name = 3; - repeated string members = 4; - optional AttachmentPointer avatar = 5; - repeated string admins = 6; + // @required + required Kind kind = 1; + // @required + required int64 seqno = 2; + // @required + required bytes data = 3; } diff --git a/SessionMessagingKit/SMKDependencies.swift b/SessionMessagingKit/SMKDependencies.swift new file mode 100644 index 000000000..34b9b9e6d --- /dev/null +++ b/SessionMessagingKit/SMKDependencies.swift @@ -0,0 +1,98 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import SessionSnodeKit +import SessionUtilitiesKit + +public class SMKDependencies: SSKDependencies { + internal var _sodium: Atomic + public var sodium: SodiumType { + get { Dependencies.getValueSettingIfNull(&_sodium) { Sodium() } } + set { _sodium.mutate { $0 = newValue } } + } + + internal var _box: Atomic + public var box: BoxType { + get { Dependencies.getValueSettingIfNull(&_box) { sodium.getBox() } } + set { _box.mutate { $0 = newValue } } + } + + internal var _genericHash: Atomic + public var genericHash: GenericHashType { + get { Dependencies.getValueSettingIfNull(&_genericHash) { sodium.getGenericHash() } } + set { _genericHash.mutate { $0 = newValue } } + } + + internal var _sign: Atomic + public var sign: SignType { + get { Dependencies.getValueSettingIfNull(&_sign) { sodium.getSign() } } + set { _sign.mutate { $0 = newValue } } + } + + internal var _aeadXChaCha20Poly1305Ietf: Atomic + public var aeadXChaCha20Poly1305Ietf: AeadXChaCha20Poly1305IetfType { + get { Dependencies.getValueSettingIfNull(&_aeadXChaCha20Poly1305Ietf) { sodium.getAeadXChaCha20Poly1305Ietf() } } + set { _aeadXChaCha20Poly1305Ietf.mutate { $0 = newValue } } + } + + internal var _ed25519: Atomic + public var ed25519: Ed25519Type { + get { Dependencies.getValueSettingIfNull(&_ed25519) { Ed25519Wrapper() } } + set { _ed25519.mutate { $0 = newValue } } + } + + internal var _nonceGenerator16: Atomic + public var nonceGenerator16: NonceGenerator16ByteType { + get { Dependencies.getValueSettingIfNull(&_nonceGenerator16) { OpenGroupAPI.NonceGenerator16Byte() } } + set { _nonceGenerator16.mutate { $0 = newValue } } + } + + internal var _nonceGenerator24: Atomic + public var nonceGenerator24: NonceGenerator24ByteType { + get { Dependencies.getValueSettingIfNull(&_nonceGenerator24) { OpenGroupAPI.NonceGenerator24Byte() } } + set { _nonceGenerator24.mutate { $0 = newValue } } + } + + // MARK: - Initialization + + public init( + subscribeQueue: DispatchQueue? = nil, + receiveQueue: DispatchQueue? = nil, + onionApi: OnionRequestAPIType.Type? = nil, + generalCache: MutableGeneralCacheType? = nil, + storage: Storage? = nil, + scheduler: ValueObservationScheduler? = 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 + ) { + _sodium = Atomic(sodium) + _box = Atomic(box) + _genericHash = Atomic(genericHash) + _sign = Atomic(sign) + _aeadXChaCha20Poly1305Ietf = Atomic(aeadXChaCha20Poly1305Ietf) + _ed25519 = Atomic(ed25519) + _nonceGenerator16 = Atomic(nonceGenerator16) + _nonceGenerator24 = Atomic(nonceGenerator24) + + super.init( + subscribeQueue: subscribeQueue, + receiveQueue: receiveQueue, + onionApi: onionApi, + generalCache: generalCache, + storage: storage, + scheduler: scheduler, + standardUserDefaults: standardUserDefaults, + date: date + ) + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift index 20e033815..9b00ee6c5 100644 --- a/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift +++ b/SessionMessagingKit/Sending & Receiving/Attachments/SignalAttachment.swift @@ -3,9 +3,8 @@ // import Foundation +import Combine import MobileCoreServices - -import PromiseKit import AVFoundation import SessionUtilitiesKit @@ -887,11 +886,16 @@ public class SignalAttachment: Equatable, Hashable { return videoDir } - public class func compressVideoAsMp4(dataSource: DataSource, dataUTI: String) -> (Promise, AVAssetExportSession?) { + public class func compressVideoAsMp4(dataSource: DataSource, dataUTI: String) -> (AnyPublisher, AVAssetExportSession?) { guard let url = dataSource.dataUrl() else { let attachment = SignalAttachment(dataSource: DataSourceValue.emptyDataSource(), dataUTI: dataUTI) attachment.error = .missingData - return (Promise.value(attachment), nil) + return ( + Just(attachment) + .setFailureType(to: Error.self) + .eraseToAnyPublisher(), + nil + ) } let asset = AVAsset(url: url) @@ -899,7 +903,12 @@ public class SignalAttachment: Equatable, Hashable { guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { let attachment = SignalAttachment(dataSource: DataSourceValue.emptyDataSource(), dataUTI: dataUTI) attachment.error = .couldNotConvertToMpeg4 - return (Promise.value(attachment), nil) + return ( + Just(attachment) + .setFailureType(to: Error.self) + .eraseToAnyPublisher(), + nil + ) } exportSession.shouldOptimizeForNetworkUse = true @@ -909,48 +918,45 @@ public class SignalAttachment: Equatable, Hashable { let exportURL = videoTempPath.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4") exportSession.outputURL = exportURL - let (promise, resolver) = Promise.pending() - - exportSession.exportAsynchronously { - let baseFilename = dataSource.sourceFilename - let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4") - - guard let dataSource = DataSourcePath.dataSource(with: exportURL, - shouldDeleteOnDeallocation: true) else { - let attachment = SignalAttachment(dataSource: DataSourceValue.emptyDataSource(), dataUTI: dataUTI) - attachment.error = .couldNotConvertToMpeg4 - resolver.fulfill(attachment) - return + let publisher = Deferred { + Future { resolver in + exportSession.exportAsynchronously { + let baseFilename = dataSource.sourceFilename + let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4") + + guard let dataSource = DataSourcePath.dataSource(with: exportURL, + shouldDeleteOnDeallocation: true) else { + let attachment = SignalAttachment(dataSource: DataSourceValue.emptyDataSource(), dataUTI: dataUTI) + attachment.error = .couldNotConvertToMpeg4 + resolver(Result.success(attachment)) + return + } + + dataSource.sourceFilename = mp4Filename + + let attachment = SignalAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String) + resolver(Result.success(attachment)) + } } - - dataSource.sourceFilename = mp4Filename - - let attachment = SignalAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String) - resolver.fulfill(attachment) } + .eraseToAnyPublisher() - return (promise, exportSession) + return (publisher, exportSession) } - @objc - public class VideoCompressionResult: NSObject { - @objc - public let attachmentPromise: AnyPromise - - @objc + public struct VideoCompressionResult { + public let attachmentPublisher: AnyPublisher public let exportSession: AVAssetExportSession? - fileprivate init(attachmentPromise: Promise, exportSession: AVAssetExportSession?) { - self.attachmentPromise = AnyPromise(attachmentPromise) + fileprivate init(attachmentPublisher: AnyPublisher, exportSession: AVAssetExportSession?) { + self.attachmentPublisher = attachmentPublisher self.exportSession = exportSession - super.init() } } - @objc public class func compressVideoAsMp4(dataSource: DataSource, dataUTI: String) -> VideoCompressionResult { - let (attachmentPromise, exportSession) = compressVideoAsMp4(dataSource: dataSource, dataUTI: dataUTI) - return VideoCompressionResult(attachmentPromise: attachmentPromise, exportSession: exportSession) + let (attachmentPublisher, exportSession) = compressVideoAsMp4(dataSource: dataSource, dataUTI: dataUTI) + return VideoCompressionResult(attachmentPublisher: attachmentPublisher, exportSession: exportSession) } @objc diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift index 74ff59dfc..8cc59e6e4 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -19,17 +19,34 @@ public enum MessageReceiverError: LocalizedError { case decryptionFailed case invalidGroupPublicKey case noGroupKeyPair + case invalidSharedConfigMessageHandling + case requiredThreadNotInConfig + case outdatedMessage public var isRetryable: Bool { switch self { case .duplicateMessage, .duplicateMessageNewSnode, .duplicateControlMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature, - .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed: + .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed, + .invalidSharedConfigMessageHandling, .requiredThreadNotInConfig, .outdatedMessage: return false default: return true } } + + public var shouldUpdateLastHash: Bool { + switch self { + // If we get one of these errors then we still want to update the last hash to prevent + // retrieving and attempting to process the same messages again (as well as ensure the + // next poll doesn't retrieve the same message - these errors are essentially considered + // "already successfully processed") + case .selfSend, .duplicateControlMessage, .outdatedMessage: + return true + + default: return false + } + } public var errorDescription: String? { switch self { @@ -51,6 +68,10 @@ public enum MessageReceiverError: LocalizedError { // Shared sender keys case .invalidGroupPublicKey: return "Invalid group public key." case .noGroupKeyPair: return "Missing group key pair." + + case .invalidSharedConfigMessageHandling: return "Invalid handling of a shared config message." + case .requiredThreadNotInConfig: return "Required thread not in config." + case .outdatedMessage: return "Message was sent before a config change which would have removed the message." } } } diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift index fb7a304d6..12cb06900 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageSenderError.swift @@ -2,7 +2,7 @@ import Foundation -public enum MessageSenderError: LocalizedError { +public enum MessageSenderError: LocalizedError, Equatable { case invalidMessage case protoConversionFailed case noUserX25519KeyPair @@ -10,6 +10,7 @@ public enum MessageSenderError: LocalizedError { case signingFailed case encryptionFailed case noUsername + case attachmentsNotUploaded // Closed groups case noThread @@ -34,6 +35,7 @@ public enum MessageSenderError: LocalizedError { case .signingFailed: return "Couldn't sign message." case .encryptionFailed: return "Couldn't encrypt message." case .noUsername: return "Missing username." + case .attachmentsNotUploaded: return "Attachments for this message have not been uploaded." // Closed groups case .noThread: return "Couldn't find a thread associated with the given group public key." @@ -42,4 +44,26 @@ public enum MessageSenderError: LocalizedError { case .other(let error): return error.localizedDescription } } + + public static func == (lhs: MessageSenderError, rhs: MessageSenderError) -> Bool { + switch (lhs, rhs) { + case (.invalidMessage, .invalidMessage): return true + case (.protoConversionFailed, .protoConversionFailed): return true + case (.noUserX25519KeyPair, .noUserX25519KeyPair): return true + case (.noUserED25519KeyPair, .noUserED25519KeyPair): return true + case (.signingFailed, .signingFailed): return true + case (.encryptionFailed, .encryptionFailed): return true + case (.noUsername, .noUsername): return true + case (.attachmentsNotUploaded, .attachmentsNotUploaded): return true + case (.noThread, .noThread): return true + case (.noKeyPair, .noKeyPair): return true + case (.invalidClosedGroupUpdate, .invalidClosedGroupUpdate): return true + + case (.other(let lhsError), .other(let rhsError)): + // Not ideal but the best we can do + return (lhsError.localizedDescription == rhsError.localizedDescription) + + default: return false + } + } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index 9f8f652db..cd85c095b 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -7,7 +7,15 @@ import SessionUtilitiesKit import SessionSnodeKit extension MessageReceiver { - public static func handleCallMessage(_ db: Database, message: CallMessage) throws { + public static func handleCallMessage( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: CallMessage + ) throws { + // Only support calls from contact threads + guard threadVariant == .contact else { return } + switch message.kind { case .preOffer: try MessageReceiver.handleNewCallMessage(db, message: message) case .offer: MessageReceiver.handleOfferCallMessage(db, message: message) @@ -38,38 +46,55 @@ extension MessageReceiver { private static func handleNewCallMessage(_ db: Database, message: CallMessage) throws { SNLog("[Calls] Received pre-offer message.") + // Determine whether the app is active based on the prefs rather than the UIApplication state to avoid + // requiring main-thread execution + let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) + // It is enough just ignoring the pre offers, other call messages // for this call would be dropped because of no Session call instance guard CurrentAppContext().isMainApp, let sender: String = message.sender, - (try? Contact.fetchOne(db, id: sender))?.isApproved == true + (try? Contact + .filter(id: sender) + .select(.isApproved) + .asRequest(of: Bool.self) + .fetchOne(db)) + .defaulting(to: false) 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 interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: .missed) { - let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: sender, variant: .contact) + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: sender, variant: .contact, shouldBeVisible: nil) - Environment.shared?.notificationsManager.wrappedValue? - .notifyUser( - db, - forIncomingCall: interaction, - in: thread - ) + if !interaction.wasRead { + Environment.shared?.notificationsManager.wrappedValue? + .notifyUser( + db, + forIncomingCall: interaction, + in: thread, + applicationState: (isMainAppActive ? .active : .background) + ) + } } return } guard db[.areCallsEnabled] else { if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: .permissionDenied) { - let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: sender, variant: .contact) + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: sender, variant: .contact, shouldBeVisible: nil) - Environment.shared?.notificationsManager.wrappedValue? - .notifyUser( - db, - forIncomingCall: interaction, - in: thread - ) + if !interaction.wasRead { + Environment.shared?.notificationsManager.wrappedValue? + .notifyUser( + db, + forIncomingCall: interaction, + in: thread, + applicationState: (isMainAppActive ? .active : .background) + ) + } // Trigger the missed call UI if needed NotificationCenter.default.post( @@ -181,6 +206,10 @@ extension MessageReceiver { SNLog("[Calls] Sending end call message because there is an ongoing call.") + let messageSentTimestamp: Int64 = ( + message.sentTimestamp.map { Int64($0) } ?? + SnodeAPI.currentOffsetTimestampMs() + ) _ = try Interaction( serverHash: message.serverHash, messageUuid: message.uuid, @@ -188,26 +217,36 @@ extension MessageReceiver { authorId: caller, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), - timestampMs: ( - message.sentTimestamp.map { Int64($0) } ?? - SnodeAPI.currentOffsetTimestampMs() + timestampMs: messageSentTimestamp, + wasRead: SessionUtil.timestampAlreadyRead( + threadId: thread.id, + threadVariant: thread.variant, + timestampMs: (messageSentTimestamp * 1000), + userPublicKey: getUserHexEncodedPublicKey(db), + openGroup: nil ) ) .inserted(db) - try MessageSender - .sendNonDurably( - db, - message: CallMessage( - uuid: message.uuid, - kind: .endCall, - sdps: [], - sentTimestampMs: nil // Explicitly nil as it's a separate message from above - ), - interactionId: nil, // Explicitly nil as it's a separate message from above - in: thread - ) - .retainUntilComplete() + MessageSender.sendImmediate( + preparedSendData: try MessageSender + .preparedSendData( + db, + message: CallMessage( + uuid: message.uuid, + kind: .endCall, + sdps: [], + sentTimestampMs: nil // Explicitly nil as it's a separate message from above + ), + to: try Message.Destination.from(db, threadId: thread.id, threadVariant: thread.variant), + namespace: try Message.Destination + .from(db, threadId: thread.id, threadVariant: thread.variant) + .defaultNamespace, + interactionId: nil // Explicitly nil as it's a separate message from above + ) + ) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() } @discardableResult public static func insertCallInfoMessage( @@ -226,9 +265,10 @@ extension MessageReceiver { !thread.isMessageRequest(db) else { return nil } + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) let messageInfo: CallMessage.MessageInfo = CallMessage.MessageInfo( state: state.defaulting( - to: (sender == getUserHexEncodedPublicKey(db) ? + to: (sender == currentUserPublicKey ? .outgoing : .incoming ) @@ -248,7 +288,14 @@ extension MessageReceiver { authorId: sender, variant: .infoCall, body: String(data: messageInfoData, encoding: .utf8), - timestampMs: timestampMs + timestampMs: timestampMs, + wasRead: SessionUtil.timestampAlreadyRead( + threadId: thread.id, + threadVariant: thread.variant, + timestampMs: (timestampMs * 1000), + userPublicKey: currentUserPublicKey, + openGroup: nil + ) ).inserted(db) } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift index 5a5321554..1a13665af 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift @@ -1,22 +1,63 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import Sodium import SessionUtilitiesKit import SessionSnodeKit extension MessageReceiver { - public static func handleClosedGroupControlMessage(_ db: Database, _ message: ClosedGroupControlMessage) throws { + public static func handleClosedGroupControlMessage( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + 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 + + case .encryptionKeyPair: + try handleClosedGroupEncryptionKeyPair( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) + + case .nameChange: + try handleClosedGroupNameChanged( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) + + case .membersAdded: + try handleClosedGroupMembersAdded( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) + + case .membersRemoved: + try handleClosedGroupMembersRemoved( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) + + case .memberLeft: + try handleClosedGroupMemberLeft( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) + + case .encryptionKeyPairRequest: break // Currently not used default: throw MessageReceiverError.invalidMessage } @@ -28,7 +69,39 @@ extension MessageReceiver { guard case let .new(publicKeyAsData, name, encryptionKeyPair, membersAsData, adminsAsData, expirationTimer) = message.kind else { return } - guard let sentTimestamp: UInt64 = message.sentTimestamp else { return } + guard + let sentTimestamp: UInt64 = message.sentTimestamp, + SessionUtil.canPerformChange( + db, + threadId: publicKeyAsData.toHexString(), + targetConfig: .userGroups, + changeTimestampMs: Int64(sentTimestamp) + ) + else { + // If the closed group already exists then store the encryption keys (since the config only stores + // the latest key we won't be able to decrypt older messages if we were added to the group within + // the last two weeks and the key has been rotated - unfortunately if the user was added more than + // two weeks ago and the keys were rotated within the last two weeks then we won't be able to decrypt + // messages received before the key rotation) + let groupPublicKey: String = publicKeyAsData.toHexString() + let receivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) + let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair( + threadId: groupPublicKey, + publicKey: Data(encryptionKeyPair.publicKey), + secretKey: Data(encryptionKeyPair.secretKey), + receivedTimestamp: receivedTimestamp + ) + + guard + ClosedGroup.filter(id: groupPublicKey).isNotEmpty(db), + !ClosedGroupKeyPair + .filter(ClosedGroupKeyPair.Columns.threadKeyPairHash == newKeyPair.threadKeyPairHash) + .isNotEmpty(db) + else { return SNLog("Ignoring outdated NEW legacy group message due to more recent config state") } + + try newKeyPair.insert(db) + return + } try handleNewClosedGroup( db, @@ -38,7 +111,8 @@ extension MessageReceiver { members: membersAsData.map { $0.toHexString() }, admins: adminsAsData.map { $0.toHexString() }, expirationTimer: expirationTimer, - messageSentTimestamp: sentTimestamp + formationTimestampMs: sentTimestamp, + calledFromConfigHandling: false ) } @@ -46,11 +120,12 @@ extension MessageReceiver { _ db: Database, groupPublicKey: String, name: String, - encryptionKeyPair: Box.KeyPair, + encryptionKeyPair: KeyPair, members: [String], admins: [String], expirationTimer: UInt32, - messageSentTimestamp: UInt64 + formationTimestampMs: UInt64, + calledFromConfigHandling: Bool ) 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 @@ -64,18 +139,18 @@ extension MessageReceiver { } } - guard hasApprovedAdmin else { return } + // If the group came from the updated config handling then it doesn't matter if we + // have an approved admin - we should add it regardless (as it's been synced from + // antoher device) + guard hasApprovedAdmin || calledFromConfigHandling 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) + .fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true) let closedGroup: ClosedGroup = try ClosedGroup( threadId: groupPublicKey, name: name, - formationTimestamp: (TimeInterval(messageSentTimestamp) / 1000) + formationTimestamp: (TimeInterval(formationTimestampMs) / 1000) ).saved(db) // Clear the zombie list if the group wasn't active (ie. had no keys) @@ -83,43 +158,27 @@ extension MessageReceiver { 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, - isHidden: false - ).save(db) - } - - try admins.forEach { adminId in - try GroupMember( - groupId: groupPublicKey, - profileId: adminId, - role: .admin, - isHidden: false - ).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) + // Create the GroupMember records if needed + try members.forEach { memberId in + try GroupMember( + groupId: groupPublicKey, + profileId: memberId, + role: .standard, + isHidden: false + ).save(db) + } + + try admins.forEach { adminId in + try GroupMember( + groupId: groupPublicKey, + profileId: adminId, + role: .admin, + isHidden: false + ).save(db) } // Update the DisappearingMessages config - try thread.disappearingMessagesConfiguration + let disappearingConfig: DisappearingMessagesConfiguration = try thread.disappearingMessagesConfiguration .fetchOne(db) .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) .with( @@ -129,18 +188,41 @@ extension MessageReceiver { (24 * 60 * 60) ) ) - .save(db) + .saved(db) - // Store the key pair - try ClosedGroupKeyPair( + // Store the key pair if it doesn't already exist + let receivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) + let newKeyPair: ClosedGroupKeyPair = ClosedGroupKeyPair( threadId: groupPublicKey, publicKey: Data(encryptionKeyPair.publicKey), secretKey: Data(encryptionKeyPair.secretKey), - receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) - ).insert(db) + receivedTimestamp: receivedTimestamp + ) + let keyPairExists: Bool = ClosedGroupKeyPair + .filter(ClosedGroupKeyPair.Columns.threadKeyPairHash == newKeyPair.threadKeyPairHash) + .isNotEmpty(db) + + if !keyPairExists { + try newKeyPair.insert(db) + } + + if !calledFromConfigHandling { + // Update libSession + try? SessionUtil.add( + db, + groupPublicKey: groupPublicKey, + name: name, + latestKeyPairPublicKey: Data(encryptionKeyPair.publicKey), + latestKeyPairSecretKey: Data(encryptionKeyPair.secretKey), + latestKeyPairReceivedTimestamp: receivedTimestamp, + disappearingConfig: disappearingConfig, + members: members.asSet(), + admins: admins.asSet() + ) + } // Start polling - ClosedGroupPoller.shared.startPolling(for: groupPublicKey) + ClosedGroupPoller.shared.startIfNeeded(for: groupPublicKey) // Notify the PN server let _ = PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: getUserHexEncodedPublicKey(db)) @@ -148,18 +230,24 @@ extension MessageReceiver { /// 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 { + private static func handleClosedGroupEncryptionKeyPair( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: ClosedGroupControlMessage + ) throws { + guard case let .encryptionKeyPair(explicitGroupPublicKey, wrappers) = message.kind else { + return + } + + let groupPublicKey: String = (explicitGroupPublicKey?.toHexString() ?? threadId) + + guard let userKeyPair: 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 { + guard let closedGroup: ClosedGroup = try? ClosedGroup.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.") @@ -193,12 +281,20 @@ extension MessageReceiver { } do { - try ClosedGroupKeyPair( + let keyPair: ClosedGroupKeyPair = ClosedGroupKeyPair( threadId: groupPublicKey, publicKey: proto.publicKey.removingIdPrefixIfNeeded(), secretKey: proto.privateKey, receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) - ).insert(db) + ) + try keyPair.insert(db) + + // Update libSession + try? SessionUtil.update( + db, + groupPublicKey: groupPublicKey, + latestKeyPair: keyPair + ) } catch { if case DatabaseError.SQLITE_CONSTRAINT_UNIQUE = error { @@ -211,305 +307,357 @@ extension MessageReceiver { 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 } + private static func handleClosedGroupNameChanged( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: ClosedGroupControlMessage + ) throws { + guard + let messageKind: ClosedGroupControlMessage.Kind = message.kind, + 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) } ?? - SnodeAPI.currentOffsetTimestampMs() + try processIfValid( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message, + messageKind: messageKind, + infoMessageVariant: .infoClosedGroupUpdated, + legacyGroupChanges: { sender, closedGroup, allMembers in + // Update libSession + try? SessionUtil.update( + db, + groupPublicKey: threadId, + name: name ) - ).inserted(db) - } + + _ = try ClosedGroup + .filter(id: threadId) + .updateAll( // Explicit config update so no need to use 'updateAllAndConfig' + db, + ClosedGroup.Columns.name.set(to: name) + ) + } + ) } - private static func handleClosedGroupMembersAdded(_ db: Database, message: ClosedGroupControlMessage) throws { - guard case let .membersAdded(membersAsData) = message.kind else { return } + private static func handleClosedGroupMembersAdded( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: ClosedGroupControlMessage + ) throws { + guard + let messageKind: ClosedGroupControlMessage.Kind = message.kind, + 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, - isHidden: false - ).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) } ?? - SnodeAPI.currentOffsetTimestampMs() + try processIfValid( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message, + messageKind: messageKind, + infoMessageVariant: .infoClosedGroupUpdated, + legacyGroupChanges: { sender, closedGroup, allMembers in + // Update the group + let addedMembers: [String] = membersAsData.map { $0.toHexString() } + let currentMemberIds: Set = allMembers + .filter { $0.role == .standard } + .map { $0.profileId } + .asSet() + + // Update libSession + try? SessionUtil.update( + db, + groupPublicKey: threadId, + members: allMembers + .filter { $0.role == .standard || $0.role == .zombie } + .map { $0.profileId } + .asSet() + .union(addedMembers), + admins: allMembers + .filter { $0.role == .admin } + .map { $0.profileId } + .asSet() ) - ).inserted(db) - } + + // Create records for any new members + try addedMembers + .filter { !currentMemberIds.contains($0) } + .forEach { memberId in + try GroupMember( + groupId: threadId, + profileId: memberId, + role: .standard, + isHidden: false + ).save(db) + } + + // Send the latest encryption key pair to the added members if the current user is + // the admin of the group + // + // This fixes a race condition where: + // • A member removes another member. + // • A member adds someone to the group and sends them the latest group key pair. + // • The admin is offline during all of this. + // • When the admin comes back online they see the member removed message and generate + + // distribute a new key pair, but they don't know about the added member yet. + // • 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 allMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) { + addedMembers.forEach { memberId in + MessageSender.sendLatestEncryptionKeyPair(db, to: memberId, for: threadId) + } + } + + // Remove any 'zombie' versions of the added members (in case they were re-added) + _ = try GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .filter(GroupMember.Columns.role == GroupMember.Role.zombie) + .filter(addedMembers.contains(GroupMember.Columns.profileId)) + .deleteAll(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 } + private static func handleClosedGroupMembersRemoved( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: ClosedGroupControlMessage + ) throws { + guard + let messageKind: ClosedGroupControlMessage.Kind = message.kind, + case let .membersRemoved(membersAsData) = messageKind + 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) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let removedMemberIds: [String] = membersAsData.map { $0.toHexString() } + + try processIfValid( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message, + messageKind: messageKind, + infoMessageVariant: (removedMemberIds.contains(userPublicKey) ? + .infoClosedGroupCurrentUserLeft : + .infoClosedGroupUpdated + ), + legacyGroupChanges: { sender, closedGroup, allMembers in + let removedMembers = membersAsData.map { $0.toHexString() } + let currentMemberIds: Set = allMembers + .filter { $0.role == .standard } + .map { $0.profileId } + .asSet() + let members = currentMemberIds.subtracting(removedMembers) - _ = try closedGroup - .keyPairs + // Check that the group creator is still a member and that the message was + // sent by a group admin + guard + let firstAdminId: String = allMembers.filter({ $0.role == .admin }) + .first? + .profileId, + members.contains(firstAdminId), + allMembers + .filter({ $0.role == .admin }) + .contains(where: { $0.profileId == sender }) + else { return SNLog("Ignoring invalid closed group update.") } + + // Update libSession + try? SessionUtil.update( + db, + groupPublicKey: threadId, + members: allMembers + .filter { $0.role == .standard || $0.role == .zombie } + .map { $0.profileId } + .asSet() + .subtracting(removedMembers), + admins: allMembers + .filter { $0.role == .admin } + .map { $0.profileId } + .asSet() + ) + + // Delete the removed members + try GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .filter(removedMembers.contains(GroupMember.Columns.profileId)) + .filter([ GroupMember.Role.standard, GroupMember.Role.zombie ].contains(GroupMember.Columns.role)) .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() - .intersection(groupMembers.map { $0.profileId }) - .map { Data(hex: $0) } + // If the current user was removed: + // • Stop polling for the group + // • Remove the key pairs associated with the group + // • Notify the PN server + let wasCurrentUserRemoved: Bool = !members.contains(userPublicKey) + + if wasCurrentUserRemoved { + try ClosedGroup.removeKeysAndUnsubscribe( + db, + threadId: threadId, + removeGroupData: true, + calledFromConfigHandling: false ) - .infoMessage(db, sender: sender), - timestampMs: ( - message.sentTimestamp.map { Int64($0) } ?? - SnodeAPI.currentOffsetTimestampMs() - ) - ).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 } + private static func handleClosedGroupMemberLeft( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: ClosedGroupControlMessage + ) throws { + guard + let messageKind: ClosedGroupControlMessage.Kind = message.kind, + case .memberLeft = messageKind + 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(membersToRemove.map{ $0.profileId }.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 processIfValid( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message, + messageKind: messageKind, + infoMessageVariant: .infoClosedGroupUpdated, + legacyGroupChanges: { sender, closedGroup, allMembers in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let didAdminLeave: Bool = allMembers.contains(where: { member in + member.role == .admin && member.profileId == sender + }) + let members: [GroupMember] = allMembers.filter { $0.role == .standard } + let memberIdsToRemove: [String] = members + .filter { member in + didAdminLeave || // If the admin leaves the group is disbanded + member.profileId == sender + } + .map { $0.profileId } - _ = try closedGroup - .keyPairs + // Update libSession + try? SessionUtil.update( + db, + groupPublicKey: threadId, + members: allMembers + .filter { $0.role == .standard || $0.role == .zombie } + .map { $0.profileId } + .asSet() + .subtracting(memberIdsToRemove), + admins: allMembers + .filter { $0.role == .admin } + .map { $0.profileId } + .asSet() + ) + + // Delete the members to remove + try GroupMember + .filter(GroupMember.Columns.groupId == threadId) + .filter(memberIdsToRemove.contains(GroupMember.Columns.profileId)) .deleteAll(db) - let _ = PushNotificationAPI.performOperation( - .unsubscribe, - for: id, - publicKey: userPublicKey - ) + if didAdminLeave || sender == userPublicKey { + try ClosedGroup.removeKeysAndUnsubscribe( + db, + threadId: threadId, + removeGroupData: (sender == userPublicKey), + calledFromConfigHandling: false + ) + } + + // Re-add the removed member as a zombie (unless the admin left which disbands the + // group) + if !didAdminLeave { + try GroupMember( + groupId: threadId, + profileId: sender, + role: .zombie, + isHidden: false + ).save(db) + } } - - // 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, - isHidden: false - ).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) } ?? - SnodeAPI.currentOffsetTimestampMs() - ) - ).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( + private static func processIfValid( _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, 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.") - } - + messageKind: ClosedGroupControlMessage.Kind, + infoMessageVariant: Interaction.Variant, + legacyGroupChanges: (String, ClosedGroup, [GroupMember]) throws -> () + ) throws { 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.") + guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: threadId) else { + return SNLog("Ignoring group update for nonexistent group.") } - try update(groupPublicKey, sender, thread, closedGroup) + let timestampMs: Int64 = ( + message.sentTimestamp.map { Int64($0) } ?? + SnodeAPI.currentOffsetTimestampMs() + ) + + // Only actually make the change if SessionUtil says we can (we always want to insert the info + // message though) + if SessionUtil.canPerformChange(db, threadId: threadId, targetConfig: .userGroups, changeTimestampMs: timestampMs) { + // Legacy groups used these control messages for making changes, new groups only use them + // for information purposes + switch threadVariant { + case .legacyGroup: + // Check that the message isn't from before the group was created + guard Double(message.sentTimestamp ?? 0) > closedGroup.formationTimestamp else { + return SNLog("Ignoring legacy group update from before thread was created.") + } + + // If these values are missing then we probably won't be able to validly handle the message + guard + let allMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db), + allMembers.contains(where: { $0.profileId == sender }) + else { return SNLog("Ignoring legacy group update from non-member.") } + + try legacyGroupChanges(sender, closedGroup, allMembers) + + case .group: + break + + default: return // Ignore as invalid + } + } + + // Ensure the group still exists before inserting the info message + guard ClosedGroup.filter(id: threadId).isNotEmpty(db) else { return } + + // Insert the info message for this group control message + _ = try Interaction( + serverHash: message.serverHash, + threadId: threadId, + authorId: sender, + variant: infoMessageVariant, + body: messageKind + .infoMessage(db, sender: sender), + timestampMs: ( + message.sentTimestamp.map { Int64($0) } ?? + SnodeAPI.currentOffsetTimestampMs() + ) + ).inserted(db) } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift index 1259720ae..1935d7619 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ConfigurationMessages.swift @@ -3,11 +3,17 @@ import Foundation import GRDB import Sodium -import SignalCoreKit +import SessionUIKit import SessionUtilitiesKit extension MessageReceiver { - internal static func handleConfigurationMessage(_ db: Database, message: ConfigurationMessage) throws { + internal static func handleLegacyConfigurationMessage(_ db: Database, message: ConfigurationMessage) throws { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard !SessionUtil.userConfigsEnabled(db) else { + TopBannerController.show(warning: .outdatedUserConfig) + return + } + let userPublicKey = getUserHexEncodedPublicKey(db) guard message.sender == userPublicKey else { return } @@ -22,22 +28,42 @@ extension MessageReceiver { .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( + // Handle user profile changes + try ProfileManager.updateProfileIfNeeded( db, publicKey: userPublicKey, name: message.displayName, - profilePictureUrl: message.profilePictureUrl, - profileKey: OWSAES256Key(data: message.profileKey), - sentTimestamp: messageSentTimestamp + avatarUpdate: { + guard + let profilePictureUrl: String = message.profilePictureUrl, + let profileKey: Data = message.profileKey + else { return .none } + + return .updateTo( + url: profilePictureUrl, + key: profileKey, + fileName: nil + ) + }(), + sentTimestamp: messageSentTimestamp, + calledFromConfigHandling: true ) - try Contact(id: userPublicKey) - .with( - isApproved: true, - didApproveMe: true - ) - .save(db) + + // Create a contact for the current user if needed (also force-approve the current user + // in case the account got into a weird state or restored directly from a migration) + let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) + + if !userContact.isTrusted || !userContact.isApproved || !userContact.didApproveMe { + try userContact.save(db) + try Contact + .filter(id: userPublicKey) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + Contact.Columns.isTrusted.set(to: true), + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe.set(to: true) + ) + } if isInitialSync || messageSentTimestamp > lastConfigTimestamp { if isInitialSync { @@ -53,12 +79,11 @@ extension MessageReceiver { // 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 + if SessionId.Prefix(from: sessionId) == .blinded15 || SessionId.Prefix(from: sessionId) == .blinded25 { + let hasUnblindedContact: Bool = BlindedIdLookup .filter(BlindedIdLookup.Columns.blindedId == sessionId) .filter(BlindedIdLookup.Columns.sessionId != nil) - .isNotEmpty(db)) - .defaulting(to: false) + .isNotEmpty(db) if hasUnblindedContact { return @@ -73,17 +98,23 @@ extension MessageReceiver { if profile.name != contactInfo.displayName || profile.profilePictureUrl != contactInfo.profilePictureUrl || - profile.profileEncryptionKey != contactInfo.profileKey.map({ OWSAES256Key(data: $0) }) + profile.profileEncryptionKey != contactInfo.profileKey { - try profile - .with( - name: contactInfo.displayName, - profilePictureUrl: .updateIf(contactInfo.profilePictureUrl), - profileEncryptionKey: .updateIf( - contactInfo.profileKey.map { OWSAES256Key(data: $0) } - ) + try profile.save(db) + try Profile + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + [ + Profile.Columns.name.set(to: contactInfo.displayName), + (contactInfo.profilePictureUrl == nil ? nil : + Profile.Columns.profilePictureUrl.set(to: contactInfo.profilePictureUrl) + ), + (contactInfo.profileKey == nil ? nil : + Profile.Columns.profileEncryptionKey.set(to: contactInfo.profileKey) + ) + ].compactMap { $0 } ) - .save(db) } /// We only update these values if the proto actually has values for them (this is to prevent an @@ -97,22 +128,23 @@ extension MessageReceiver { (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 - ) + try contact.save(db) + try Contact + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + [ + (!contactInfo.hasIsApproved || !contactInfo.isApproved ? nil : + Contact.Columns.isApproved.set(to: true) + ), + (!contactInfo.hasIsBlocked ? nil : + Contact.Columns.isBlocked.set(to: contactInfo.isBlocked) + ), + (!contactInfo.hasDidApproveMe || !contactInfo.didApproveMe ? nil : + Contact.Columns.didApproveMe.set(to: contactInfo.didApproveMe) + ) + ].compactMap { $0 } ) - .save(db) } // If the contact is blocked @@ -138,7 +170,7 @@ extension MessageReceiver { // past two weeks) if isInitialSync { let existingClosedGroupsIds: [String] = (try? SessionThread - .filter(SessionThread.Columns.variant == SessionThread.Variant.closedGroup) + .filter(SessionThread.Columns.variant == SessionThread.Variant.legacyGroup) .fetchAll(db)) .defaulting(to: []) .map { $0.id } @@ -146,7 +178,7 @@ extension MessageReceiver { try message.closedGroups.forEach { closedGroup in guard !existingClosedGroupsIds.contains(closedGroup.publicKey) else { return } - let keyPair: Box.KeyPair = Box.KeyPair( + let keyPair: KeyPair = KeyPair( publicKey: closedGroup.encryptionKeyPublicKey.bytes, secretKey: closedGroup.encryptionKeySecretKey.bytes ) @@ -159,17 +191,37 @@ extension MessageReceiver { members: [String](closedGroup.members), admins: [String](closedGroup.admins), expirationTimer: closedGroup.expirationTimer, - messageSentTimestamp: message.sentTimestamp! + formationTimestampMs: message.sentTimestamp!, + calledFromConfigHandling: false // Legacy config isn't an issue ) } } // 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() + if let (room, server, publicKey) = SessionUtil.parseCommunity(url: openGroupURL) { + let successfullyAddedGroup: Bool = OpenGroupManager.shared + .add( + db, + roomToken: room, + server: server, + publicKey: publicKey, + calledFromConfigHandling: true + ) + + if successfullyAddedGroup { + db.afterNextTransactionNested { _ in + OpenGroupManager.shared.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: room, + server: server, + publicKey: publicKey, + calledFromConfigHandling: false + ) + .subscribe(on: OpenGroupAPI.workQueue) + .sinkUntilComplete() + } + } } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift index a28346f6d..db91fb8ea 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -3,19 +3,28 @@ import Foundation import GRDB import SessionSnodeKit +import SessionUtilitiesKit extension MessageReceiver { - internal static func handleDataExtractionNotification(_ db: Database, message: DataExtractionNotification) throws { + internal static func handleDataExtractionNotification( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: DataExtractionNotification + ) throws { guard + threadVariant == .contact, 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 } + let messageKind: DataExtractionNotification.Kind = message.kind + else { throw MessageReceiverError.invalidMessage } + let timestampMs: Int64 = ( + message.sentTimestamp.map { Int64($0) } ?? + SnodeAPI.currentOffsetTimestampMs() + ) _ = try Interaction( serverHash: message.serverHash, - threadId: thread.id, + threadId: threadId, authorId: sender, variant: { switch messageKind { @@ -23,9 +32,13 @@ extension MessageReceiver { case .mediaSaved: return .infoMediaSavedNotification } }(), - timestampMs: ( - message.sentTimestamp.map { Int64($0) } ?? - SnodeAPI.currentOffsetTimestampMs() + timestampMs: timestampMs, + wasRead: SessionUtil.timestampAlreadyRead( + threadId: threadId, + threadVariant: threadVariant, + timestampMs: (timestampMs * 1000), + userPublicKey: getUserHexEncodedPublicKey(db), + openGroup: nil ) ).inserted(db) } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift index bb5f3bfaa..60a95f4ca 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ExpirationTimers.swift @@ -5,22 +5,27 @@ import GRDB import SessionUtilitiesKit extension MessageReceiver { - internal static func handleExpirationTimerUpdate(_ db: Database, message: ExpirationTimerUpdate) throws { - // Get the target thread + internal static func handleExpirationTimerUpdate( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: ExpirationTimerUpdate + ) throws { 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 } + // Only process these for contact and legacy groups (new groups handle it separately) + (threadVariant == .contact || threadVariant == .legacyGroup), + let sender: String = message.sender + else { throw MessageReceiverError.invalidMessage } - // Update the configuration + // Generate an updated 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 + let config: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration + .filter(id: threadId) .fetchOne(db) - .defaulting(to: DisappearingMessagesConfiguration.defaultWith(thread.id)) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId)) .with( // If there is no duration then we should disable the expiration timer isEnabled: ((message.duration ?? 0) > 0), @@ -29,24 +34,70 @@ extension MessageReceiver { DisappearingMessagesConfiguration.defaultDuration ) ) + let timestampMs: Int64 = Int64(message.sentTimestamp ?? 0) // Default to `0` if not set + + // Only actually make the change if SessionUtil says we can (we always want to insert the info + // message though) + let canPerformChange: Bool = SessionUtil.canPerformChange( + db, + threadId: threadId, + targetConfig: { + switch threadVariant { + case .contact: + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + + return (threadId == currentUserPublicKey ? .userProfile : .contacts) + + default: return .userGroups + } + }(), + changeTimestampMs: timestampMs + ) + + // Only update libSession if we can perform the change + if canPerformChange { + // Legacy closed groups need to update the SessionUtil + switch threadVariant { + case .legacyGroup: + try SessionUtil + .update( + db, + groupPublicKey: threadId, + disappearingConfig: config + ) + + default: break + } + } // Add an info message for the user + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) _ = try Interaction( serverHash: nil, // Intentionally null so sync messages are seen as duplicates - threadId: thread.id, + threadId: threadId, authorId: sender, variant: .infoDisappearingMessagesUpdate, body: config.messageInfoString( - with: (sender != getUserHexEncodedPublicKey(db) ? + with: (sender != currentUserPublicKey ? Profile.displayName(db, id: sender) : nil ) ), - timestampMs: Int64(message.sentTimestamp ?? 0) // Default to `0` if not set + timestampMs: timestampMs, + wasRead: SessionUtil.timestampAlreadyRead( + threadId: threadId, + threadVariant: threadVariant, + timestampMs: (timestampMs * 1000), + userPublicKey: currentUserPublicKey, + openGroup: nil + ) ).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) + // Only save the updated config if we can perform the change + if canPerformChange { + // Finally save the changes to the DisappearingMessagesConfiguration (If it's a duplicate + // then the interaction unique constraint will prevent the code from getting here) + try config.save(db) + } } } diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 06ee54eb3..1582d5896 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -1,8 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import SignalCoreKit import SessionUtilitiesKit import SessionSnodeKit @@ -16,36 +16,49 @@ extension MessageReceiver { 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 } + guard + message.sender != userPublicKey, + let senderId: String = message.sender + else { throw MessageReceiverError.invalidMessage } // 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 let messageSentTimestamp: TimeInterval = (TimeInterval(message.sentTimestamp ?? 0) / 1000) - if let profileKey = profile.profileKey { contactProfileKey = OWSAES256Key(data: profileKey) } - - try MessageReceiver.updateProfileIfNeeded( + try ProfileManager.updateProfileIfNeeded( db, publicKey: senderId, name: profile.displayName, - profilePictureUrl: profile.profilePictureUrl, - profileKey: contactProfileKey, + avatarUpdate: { + guard + let profilePictureUrl: String = profile.profilePictureUrl, + let profileKey: Data = profile.profileKey + else { return .none } + + return .updateTo( + url: profilePictureUrl, + key: profileKey, + fileName: nil + ) + }(), sentTimestamp: messageSentTimestamp ) } // Prep the unblinded thread - let unblindedThread: SessionThread = try SessionThread.fetchOrCreate(db, id: senderId, variant: .contact) + let unblindedThread: SessionThread = try SessionThread + .fetchOrCreate(db, id: senderId, variant: .contact, shouldBeVisible: nil) // 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)%")) + .filter( + SessionThread.Columns.id.like("\(SessionId.Prefix.blinded15.rawValue)%") || + SessionThread.Columns.id.like("\(SessionId.Prefix.blinded25.rawValue)%") + ) .asRequest(of: String.self) .fetchSet(db)) .defaulting(to: []) @@ -57,7 +70,8 @@ extension MessageReceiver { // 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 + // If the sessionId matches the blindedId then this thread needs to be converted to an + // un-blinded thread guard dependencies.sodium.sessionId( senderId, @@ -84,16 +98,20 @@ extension MessageReceiver { .updateAll(db, Interaction.Columns.threadId.set(to: unblindedThread.id)) _ = try SessionThread - .filter(id: blindedIdLookup.blindedId) - .deleteAll(db) + .deleteOrLeave( + db, + threadId: blindedIdLookup.blindedId, + threadVariant: .contact, + groupLeaveType: .forced, + calledFromConfigHandling: false + ) } // 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 + threadId: nil ) // If there were blinded contacts which have now been resolved to this contact then we should remove @@ -107,8 +125,7 @@ extension MessageReceiver { try updateContactApprovalStatusIfNeeded( db, senderSessionId: userPublicKey, - threadId: unblindedThread.id, - forceConfigSync: true + threadId: unblindedThread.id ) } @@ -132,8 +149,7 @@ extension MessageReceiver { internal static func updateContactApprovalStatusIfNeeded( _ db: Database, senderSessionId: String, - threadId: String?, - forceConfigSync: Bool + threadId: String? ) throws { let userPublicKey: String = getUserHexEncodedPublicKey(db) @@ -153,9 +169,10 @@ extension MessageReceiver { guard !contact.isApproved else { return } - _ = try? contact - .with(isApproved: true) - .saved(db) + try? contact.save(db) + _ = try? Contact + .filter(id: threadId) + .updateAllAndConfig(db, Contact.Columns.isApproved.set(to: true)) } else { // The message was sent to the current user so flag their 'didApproveMe' as true (can't send a message to @@ -164,14 +181,10 @@ extension MessageReceiver { guard !contact.didApproveMe else { return } - _ = try? contact - .with(didApproveMe: true) - .saved(db) + try? contact.save(db) + _ = try? Contact + .filter(id: senderSessionId) + .updateAllAndConfig(db, Contact.Columns.didApproveMe.set(to: true)) } - - // 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+TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift index 46807cf60..91a00bd41 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+TypingIndicators.swift @@ -5,28 +5,46 @@ 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 } + internal static func handleTypingIndicator( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: TypingIndicator + ) throws { + guard try SessionThread.exists(db, id: threadId) else { return } switch message.kind { case .started: + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let threadIsBlocked: Bool = ( + threadVariant == .contact && + (try? Contact + .filter(id: threadId) + .select(.isBlocked) + .asRequest(of: Bool.self) + .fetchOne(db)) + .defaulting(to: false) + ) + let threadIsMessageRequest: Bool = (try? SessionThread + .filter(id: threadId) + .filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey, includeNonVisible: true)) + .isEmpty(db)) + .defaulting(to: false) let needsToStartTypingIndicator: Bool = TypingIndicators.didStartTypingNeedsToStart( - threadId: thread.id, - threadVariant: thread.variant, - threadIsMessageRequest: thread.isMessageRequest(db), + threadId: threadId, + threadVariant: threadVariant, + threadIsBlocked: threadIsBlocked, + threadIsMessageRequest: threadIsMessageRequest, direction: .incoming, timestampMs: message.sentTimestamp.map { Int64($0) } ) if needsToStartTypingIndicator { - TypingIndicators.start(db, threadId: thread.id, direction: .incoming) + TypingIndicators.start(db, threadId: threadId, direction: .incoming) } case .stopped: - TypingIndicators.didStopTyping(db, threadId: thread.id, direction: .incoming) + TypingIndicators.didStopTyping(db, threadId: threadId, direction: .incoming) default: SNLog("Unknown TypingIndicator Kind ignored") diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift index e2cc442ea..ea80c22ff 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+UnsendRequests.swift @@ -6,7 +6,12 @@ import SessionSnodeKit import SessionUtilitiesKit extension MessageReceiver { - public static func handleUnsendRequest(_ db: Database, message: UnsendRequest) throws { + public static func handleUnsendRequest( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + message: UnsendRequest + ) throws { let userPublicKey: String = getUserHexEncodedPublicKey(db) guard message.sender == message.author || userPublicKey == message.sender else { return } @@ -19,12 +24,7 @@ extension MessageReceiver { guard let interactionId: Int64 = maybeInteraction?.id, - let interaction: Interaction = maybeInteraction, - let threadVariant: SessionThread.Variant = try SessionThread - .filter(id: interaction.threadId) - .select(.variant) - .asRequest(of: SessionThread.Variant.self) - .fetchOne(db) + let interaction: Interaction = maybeInteraction else { return } // Mark incoming messages as read and remove any of their notifications @@ -43,7 +43,13 @@ extension MessageReceiver { } if author == message.sender, let serverHash: String = interaction.serverHash { - SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete() + SnodeAPI + .deleteMessages( + publicKey: author, + serverHashes: [serverHash] + ) + .subscribe(on: DispatchQueue.global(qos: .background)) + .sinkUntilComplete() } switch (interaction.variant, (author == message.sender)) { diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index f0f96e1bb..7f972a4c3 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -3,15 +3,16 @@ import Foundation import GRDB import Sodium -import SignalCoreKit +import SessionSnodeKit import SessionUtilitiesKit extension MessageReceiver { @discardableResult public static func handleVisibleMessage( _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, message: VisibleMessage, associatedWithProto proto: SNProtoContent, - openGroupId: String?, dependencies: Dependencies = Dependencies() ) throws -> Int64 { guard let sender: String = message.sender, let dataMessage = proto.dataMessage else { @@ -26,33 +27,55 @@ extension MessageReceiver { // 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( + try ProfileManager.updateProfileIfNeeded( db, publicKey: sender, name: profile.displayName, - profilePictureUrl: profile.profilePictureUrl, - profileKey: contactProfileKey, + avatarUpdate: { + guard + let profilePictureUrl: String = profile.profilePictureUrl, + let profileKey: Data = profile.profileKey + else { return .remove } + + return .updateTo( + url: profilePictureUrl, + key: profileKey, + fileName: nil + ) + }(), 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 + switch threadVariant { + case .contact: break // Always continue + + case .community: + // Only process visible messages for communities if they have an existing thread + guard (try? SessionThread.exists(db, id: threadId)) == true else { + throw MessageReceiverError.noThread + } + + case .legacyGroup, .group: + // Only process visible messages for groups if they have a ClosedGroup record + guard (try? ClosedGroup.exists(db, id: threadId)) == true 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) + .fetchOrCreate(db, id: threadId, variant: threadVariant, shouldBeVisible: nil) + let maybeOpenGroup: OpenGroup? = { + guard threadVariant == .community else { return nil } + + return try? OpenGroup.fetchOne(db, id: threadId) + }() let variant: Interaction.Variant = { guard - let openGroupId: String = openGroupId, let senderSessionId: SessionId = SessionId(from: sender), - let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: openGroupId) + let openGroup: OpenGroup = maybeOpenGroup else { return (sender == currentUserPublicKey ? .standardOutgoing : @@ -62,19 +85,24 @@ extension MessageReceiver { // Need to check if the blinded id matches for open groups switch senderSessionId.prefix { - case .blinded: + case .blinded15, .blinded25: let sodium: Sodium = Sodium() guard - let userEdKeyPair: Box.KeyPair = Identity.fetchUserEd25519KeyPair(db), - let blindedKeyPair: Box.KeyPair = sodium.blindedKeyPair( + let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), + let blindedKeyPair: KeyPair = sodium.blindedKeyPair( serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, genericHash: sodium.genericHash ) else { return .standardIncoming } - return (sender == SessionId(.blinded, publicKey: blindedKeyPair.publicKey).hexString ? + let senderIdCurrentUserBlinded: Bool = ( + sender == SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString || + sender == SessionId(.blinded25, publicKey: blindedKeyPair.publicKey).hexString + ) + + return (senderIdCurrentUserBlinded ? .standardOutgoing : .standardIncoming ) @@ -88,7 +116,15 @@ extension MessageReceiver { }() // Handle emoji reacts first (otherwise it's essentially an invalid message) - if let interactionId: Int64 = try handleEmojiReactIfNeeded(db, message: message, associatedWithProto: proto, sender: sender, messageSentTimestamp: messageSentTimestamp, openGroupId: openGroupId, thread: thread) { + if let interactionId: Int64 = try handleEmojiReactIfNeeded( + db, + thread: thread, + message: message, + associatedWithProto: proto, + sender: sender, + messageSentTimestamp: messageSentTimestamp, + openGroup: maybeOpenGroup + ) { return interactionId } @@ -112,7 +148,17 @@ extension MessageReceiver { variant: variant, body: message.text, timestampMs: Int64(messageSentTimestamp * 1000), - wasRead: (variant == .standardOutgoing), // Auto-mark sent messages as read + wasRead: ( + // Auto-mark sent messages or messages older than the 'lastReadTimestampMs' as read + variant == .standardOutgoing || + SessionUtil.timestampAlreadyRead( + threadId: thread.id, + threadVariant: thread.variant, + timestampMs: Int64(messageSentTimestamp * 1000), + userPublicKey: currentUserPublicKey, + openGroup: maybeOpenGroup + ) + ), hasMention: Interaction.isUserMentioned( db, threadId: thread.id, @@ -157,7 +203,7 @@ extension MessageReceiver { // 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( + try updateRecipientAndReadStatesForOutgoingInteraction( db, thread: thread, interactionId: existingInteractionId, @@ -175,7 +221,7 @@ extension MessageReceiver { guard let interactionId: Int64 = interaction.id else { throw StorageError.failedToSave } // Update and recipient and read states as needed - try updateRecipientAndReadStates( + try updateRecipientAndReadStatesForOutgoingInteraction( db, thread: thread, interactionId: interactionId, @@ -278,20 +324,20 @@ extension MessageReceiver { try MessageReceiver.updateContactApprovalStatusIfNeeded( db, senderSessionId: sender, - threadId: thread.id, - forceConfigSync: false + threadId: thread.id ) } // Notify the user if needed - guard variant == .standardIncoming else { return interactionId } + guard variant == .standardIncoming && !interaction.wasRead 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 + in: thread, + applicationState: (isMainAppActive ? .active : .background) ) return interactionId @@ -299,12 +345,12 @@ extension MessageReceiver { private static func handleEmojiReactIfNeeded( _ db: Database, + thread: SessionThread, message: VisibleMessage, associatedWithProto proto: SNProtoContent, sender: String, messageSentTimestamp: TimeInterval, - openGroupId: String?, - thread: SessionThread + openGroup: OpenGroup? ) throws -> Int64? { guard let reaction: VisibleMessage.VMReaction = message.reaction, @@ -332,22 +378,37 @@ extension MessageReceiver { switch reaction.kind { case .react: - let reaction = Reaction( + // Determine whether the app is active based on the prefs rather than the UIApplication state to avoid + // requiring main-thread execution + let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) + let timestampMs: Int64 = Int64(messageSentTimestamp * 1000) + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) + let reaction: Reaction = try Reaction( interactionId: interactionId, serverHash: message.serverHash, - timestampMs: Int64(messageSentTimestamp * 1000), + timestampMs: timestampMs, authorId: sender, emoji: reaction.emoji, count: 1, sortId: sortId + ).inserted(db) + let timestampAlreadyRead: Bool = SessionUtil.timestampAlreadyRead( + threadId: thread.id, + threadVariant: thread.variant, + timestampMs: timestampMs, + userPublicKey: currentUserPublicKey, + openGroup: openGroup ) - try reaction.insert(db) - if sender != getUserHexEncodedPublicKey(db) { + + // Don't notify if the reaction was added before the lastest read timestamp for + // the conversation + if sender != currentUserPublicKey && !timestampAlreadyRead { Environment.shared?.notificationsManager.wrappedValue? .notifyUser( db, forReaction: reaction, - in: thread + in: thread, + applicationState: (isMainAppActive ? .active : .background) ) } case .remove: @@ -361,7 +422,7 @@ extension MessageReceiver { return interactionId } - private static func updateRecipientAndReadStates( + private static func updateRecipientAndReadStatesForOutgoingInteraction( _ db: Database, thread: SessionThread, interactionId: Int64, @@ -388,7 +449,7 @@ extension MessageReceiver { ).save(db) } - case .closedGroup: + case .legacyGroup, .group: try GroupMember .filter(GroupMember.Columns.groupId == thread.id) .fetchAll(db) @@ -400,7 +461,7 @@ extension MessageReceiver { ).save(db) } - case .openGroup: + case .community: try RecipientState( interactionId: interactionId, recipientId: thread.id, // For open groups this will always be the thread id @@ -417,7 +478,7 @@ extension MessageReceiver { threadId: thread.id, threadVariant: thread.variant, includingOlder: true, - trySendReadReceipt: true + trySendReadReceipt: false ) // Process any PendingReadReceipt values diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift index 20f1c8574..5cecfca71 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift @@ -1,123 +1,144 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import Sodium import Curve25519Kit -import PromiseKit import SessionUtilitiesKit import SessionSnodeKit 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 = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) - 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, - isHidden: false - ).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, - isHidden: false - ).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( + public static func createClosedGroup( + name: String, + members: Set + ) -> AnyPublisher { + Storage.shared + .writePublisher { db -> (String, SessionThread, [MessageSender.PreparedSendData]) in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + var members: Set = members + + // Generate the group's public key + let groupKeyPair: ECKeyPair = Curve25519.generateKeyPair() + let groupPublicKey: String = KeyPair( + publicKey: groupKeyPair.publicKey.bytes, + secretKey: groupKeyPair.privateKey.bytes + ).hexEncodedPublicKey // Includes the 'SessionId.Prefix.standard' prefix + // Generate the key pair that'll be used for encryption and decryption + let encryptionKeyPair: ECKeyPair = Curve25519.generateKeyPair() + + // Create the group + members.insert(userPublicKey) // Ensure the current user is included in the member list + let membersAsData: [Data] = members.map { Data(hex: $0) } + let admins: Set = [ userPublicKey ] + let adminsAsData: [Data] = admins.map { Data(hex: $0) } + let formationTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) + + // Create the relevant objects in the database + let thread: SessionThread = try SessionThread + .fetchOrCreate(db, id: groupPublicKey, variant: .legacyGroup, shouldBeVisible: true) + try ClosedGroup( + threadId: groupPublicKey, + name: name, + formationTimestamp: formationTimestamp + ).insert(db) + + // Store the key pair + let latestKeyPairReceivedTimestamp: TimeInterval = (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) + try ClosedGroupKeyPair( + threadId: groupPublicKey, + publicKey: encryptionKeyPair.publicKey, + secretKey: encryptionKeyPair.privateKey, + receivedTimestamp: latestKeyPairReceivedTimestamp + ).insert(db) + + // Create the member objects + try admins.forEach { adminId in + try GroupMember( + groupId: groupPublicKey, + profileId: adminId, + role: .admin, + isHidden: false + ).save(db) + } + + try members.forEach { memberId in + try GroupMember( + groupId: groupPublicKey, + profileId: memberId, + role: .standard, + isHidden: false + ).save(db) + } + + // Update libSession + try SessionUtil.add( 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 + groupPublicKey: groupPublicKey, + name: name, + latestKeyPairPublicKey: encryptionKeyPair.publicKey, + latestKeyPairSecretKey: encryptionKeyPair.privateKey, + latestKeyPairReceivedTimestamp: latestKeyPairReceivedTimestamp, + disappearingConfig: DisappearingMessagesConfiguration.defaultWith(groupPublicKey), + members: members, + admins: admins ) + + let memberSendData: [MessageSender.PreparedSendData] = try members + .map { memberId -> MessageSender.PreparedSendData in + try MessageSender.preparedSendData( + db, + message: ClosedGroupControlMessage( + kind: .new( + publicKey: Data(hex: groupPublicKey), + name: name, + encryptionKeyPair: 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)) + ), + to: .contact(publicKey: memberId), + namespace: Message.Destination.contact(publicKey: memberId).defaultNamespace, + interactionId: nil + ) + } + + return (userPublicKey, thread, memberSendData) + } + .flatMap { userPublicKey, thread, memberSendData in + Publishers + .MergeMany( + // Send a closed group update message to all members individually + memberSendData + .map { MessageSender.sendImmediate(preparedSendData: $0) } + .appending( + // Notify the PN server + PushNotificationAPI.performOperation( + .subscribe, + for: thread.id, + publicKey: userPublicKey + ) + ) + ) + .collect() + .map { _ in thread } + } + .handleEvents( + receiveOutput: { thread in + // Start polling + ClosedGroupPoller.shared.startIfNeeded(for: thread.id) + } ) - } - - // Store the key pair - try ClosedGroupKeyPair( - threadId: groupPublicKey, - publicKey: encryptionKeyPair.publicKey, - secretKey: encryptionKeyPair.privateKey, - receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) - ).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: SnodeAPI.currentOffsetTimestampMs() - ).inserted(db) - - // Start polling - ClosedGroupPoller.shared.startPolling(for: groupPublicKey) - - return when(fulfilled: promises).map2 { thread } + .eraseToAnyPublisher() } /// Generates and distributes a new encryption key pair for the group with the given closed group. This sends an @@ -127,172 +148,212 @@ extension MessageSender { /// /// 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 { + closedGroup: ClosedGroup + ) -> AnyPublisher { 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: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) - ) - - // 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) + return Fail(error: MessageSenderError.invalidClosedGroupUpdate) + .eraseToAnyPublisher() } - 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 + return Storage.shared + .readPublisher { db -> (ClosedGroupKeyPair, MessageSender.PreparedSendData) in + // 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: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) ) - .done { + + // 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) + } + + let sendData: MessageSender.PreparedSendData = try MessageSender + .preparedSendData( + db, + message: ClosedGroupControlMessage( + kind: .encryptionKeyPair( + publicKey: nil, + wrappers: targetMembers.map { memberPublicKey in + ClosedGroupControlMessage.KeyPairWrapper( + publicKey: memberPublicKey, + encryptedKeyPair: try MessageSender.encryptWithSessionProtocol( + db, + plaintext: plaintext, + for: memberPublicKey + ) + ) + } + ) + ), + to: try Message.Destination + .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup), + namespace: try Message.Destination + .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup) + .defaultNamespace, + interactionId: nil + ) + + return (newKeyPair, sendData) + } + .flatMap { newKeyPair, sendData -> AnyPublisher in + MessageSender.sendImmediate(preparedSendData: sendData) + .map { _ in newKeyPair } + .eraseToAnyPublisher() + } + .handleEvents( + receiveOutput: { newKeyPair in /// 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) - } + // Update libSession + try? SessionUtil.update( + db, + groupPublicKey: closedGroup.threadId, + latestKeyPair: newKeyPair, + members: allGroupMembers + .filter { $0.role == .standard || $0.role == .zombie } + .map { $0.profileId } + .asSet(), + admins: allGroupMembers + .filter { $0.role == .admin } + .map { $0.profileId } + .asSet() + ) + } + + 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) - } + ) + .map { _ in () } + .eraseToAnyPublisher() } 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: SnodeAPI.currentOffsetTimestampMs() - ).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 + ) -> AnyPublisher { + return Storage.shared + .writePublisher { db -> (String, ClosedGroup, [GroupMember], Set) in + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + // Get the group, check preconditions & prepare + guard (try? SessionThread.exists(db, id: groupPublicKey)) == true else { + SNLog("Can't update nonexistent closed group.") + throw MessageSenderError.noThread + } + guard let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: groupPublicKey) else { + throw MessageSenderError.invalidClosedGroupUpdate + } + + // 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: groupPublicKey, + authorId: userPublicKey, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .nameChange(name: name) + .infoMessage(db, sender: userPublicKey), + timestampMs: SnodeAPI.currentOffsetTimestampMs() + ).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: .nameChange(name: name)), + interactionId: interactionId, + threadId: groupPublicKey, + threadVariant: .legacyGroup + ) + + // Update libSession + try? SessionUtil.update( + db, + groupPublicKey: closedGroup.threadId, + name: name + ) + } + + // Retrieve member info + guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else { + throw 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 + ) + } + catch { + throw MessageSenderError.invalidClosedGroupUpdate + } + } + + // Remove members if needed + return ( + userPublicKey, + closedGroup, + allGroupMembers, + Set(standardAndZombieMemberIds).subtracting(members) ) } - 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, + .flatMap { userPublicKey, closedGroup, allGroupMembers, removedMembers -> AnyPublisher in + guard !removedMembers.isEmpty else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return removeMembers( removedMembers: removedMembers, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, - closedGroup: closedGroup, - thread: thread + closedGroup: closedGroup ) + .catch { _ in Fail(error: MessageSenderError.invalidClosedGroupUpdate).eraseToAnyPublisher() } + .eraseToAnyPublisher() } - catch { - return Promise(error: MessageSenderError.invalidClosedGroupUpdate) - } - } - - return Promise.value(()) + .eraseToAnyPublisher() } @@ -303,10 +364,9 @@ extension MessageSender { addedMembers: Set, userPublicKey: String, allGroupMembers: [GroupMember], - closedGroup: ClosedGroup, - thread: SessionThread + closedGroup: ClosedGroup ) throws { - guard let disappearingMessagesConfig: DisappearingMessagesConfiguration = try thread.disappearingMessagesConfiguration.fetchOne(db) else { + guard let disappearingMessagesConfig: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration.fetchOne(db, id: closedGroup.threadId) else { throw StorageError.objectNotFound } guard let encryptionKeyPair: ClosedGroupKeyPair = try closedGroup.fetchLatestKeyPair(db) else { @@ -325,7 +385,7 @@ extension MessageSender { // Notify the user let interaction: Interaction = try Interaction( - threadId: thread.id, + threadId: closedGroup.threadId, authorId: userPublicKey, variant: .infoClosedGroupUpdated, body: ClosedGroupControlMessage.Kind @@ -336,6 +396,21 @@ extension MessageSender { guard let interactionId: Int64 = interaction.id else { throw StorageError.objectNotSaved } + // Update libSession + try? SessionUtil.update( + db, + groupPublicKey: closedGroup.threadId, + members: allGroupMembers + .filter { $0.role == .standard || $0.role == .zombie } + .map { $0.profileId } + .asSet() + .union(addedMembers), + admins: allGroupMembers + .filter { $0.role == .admin } + .map { $0.profileId } + .asSet() + ) + // Send the update to the group try MessageSender.send( db, @@ -343,13 +418,13 @@ extension MessageSender { kind: .membersAdded(members: addedMembers.map { Data(hex: $0) }) ), interactionId: interactionId, - in: thread + threadId: closedGroup.threadId, + threadVariant: .legacyGroup ) try addedMembers.forEach { member in // Send updates to the new members individually - let thread: SessionThread = try SessionThread - .fetchOrCreate(db, id: member, variant: .contact) + try SessionThread.fetchOrCreate(db, id: member, variant: .contact, shouldBeVisible: nil) try MessageSender.send( db, @@ -357,7 +432,7 @@ extension MessageSender { kind: .new( publicKey: Data(hex: closedGroup.id), name: closedGroup.name, - encryptionKeyPair: Box.KeyPair( + encryptionKeyPair: KeyPair( publicKey: encryptionKeyPair.publicKey.bytes, secretKey: encryptionKeyPair.secretKey.bytes ), @@ -370,7 +445,8 @@ extension MessageSender { ) ), interactionId: nil, - in: thread + threadId: member, + threadVariant: .contact ) // Add the users to the group @@ -379,7 +455,7 @@ extension MessageSender { profileId: member, role: .standard, isHidden: false - ).insert(db) + ).save(db) } } @@ -390,20 +466,20 @@ extension MessageSender { /// 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 { + closedGroup: ClosedGroup + ) -> AnyPublisher { guard !removedMembers.contains(userPublicKey) else { SNLog("Invalid closed group update.") - throw MessageSenderError.invalidClosedGroupUpdate + return Fail(error: MessageSenderError.invalidClosedGroupUpdate) + .eraseToAnyPublisher() } guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else { SNLog("Only an admin can remove members from a group.") - throw MessageSenderError.invalidClosedGroupUpdate + return Fail(error: MessageSenderError.invalidClosedGroupUpdate) + .eraseToAnyPublisher() } let groupMemberIds: [String] = allGroupMembers @@ -414,60 +490,64 @@ extension MessageSender { .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: SnodeAPI.currentOffsetTimestampMs() - ).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) } + return Storage.shared + .writePublisher { db in + // Update zombie & member list + try GroupMember + .filter(GroupMember.Columns.groupId == closedGroup.threadId) + .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: closedGroup.threadId, + authorId: userPublicKey, + variant: .infoClosedGroupUpdated, + body: ClosedGroupControlMessage.Kind + .membersRemoved(members: removedMembers.map { Data(hex: $0) }) + .infoMessage(db, sender: userPublicKey), + timestampMs: SnodeAPI.currentOffsetTimestampMs() + ).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 + return try MessageSender + .preparedSendData( + db, + message: ClosedGroupControlMessage( + kind: .membersRemoved( + members: removedMembers.map { Data(hex: $0) } + ) + ), + to: try Message.Destination + .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup), + namespace: try Message.Destination + .from(db, threadId: closedGroup.threadId, threadVariant: .legacyGroup) + .defaultNamespace, + interactionId: interactionId ) - ), - interactionId: interactionId, - in: thread - ) - .map { _ in - try generateAndSendNewEncryptionKeyPair( - db, + } + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .flatMap { _ -> AnyPublisher in + MessageSender.generateAndSendNewEncryptionKeyPair( targetMembers: members, userPublicKey: userPublicKey, allGroupMembers: allGroupMembers, - closedGroup: closedGroup, - thread: thread + closedGroup: closedGroup ) } - .map { _ in } - - return promise + .eraseToAnyPublisher() } /// Leave the group with the given `groupPublicKey`. If the current user is the admin, the group is disbanded entirely. If the @@ -478,16 +558,16 @@ extension MessageSender { /// 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, deleteThread: Bool) throws { - guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else { - return - } - + public static func leave( + _ db: Database, + groupPublicKey: String, + deleteThread: Bool + ) throws { let userPublicKey: String = getUserHexEncodedPublicKey(db) // Notify the user let interaction: Interaction = try Interaction( - threadId: thread.id, + threadId: groupPublicKey, authorId: userPublicKey, variant: .infoClosedGroupCurrentUserLeaving, body: "group_you_leaving".localized(), @@ -498,37 +578,20 @@ extension MessageSender { db, job: Job( variant: .groupLeaving, - threadId: thread.id, + threadId: groupPublicKey, interactionId: interaction.id, details: GroupLeavingJob.Details( - groupPublicKey: groupPublicKey, deleteThread: deleteThread ) ) ) } - /* - 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) { + 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.") } @@ -559,8 +622,12 @@ extension MessageSender { ).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) + .fetchOrCreate(db, id: publicKey, variant: .contact, shouldBeVisible: nil) + let ciphertext = try MessageSender.encryptWithSessionProtocol( + db, + plaintext: plaintext, + for: publicKey + ) SNLog("Sending latest encryption key pair to: \(publicKey).") try MessageSender.send( @@ -577,7 +644,8 @@ extension MessageSender { ) ), interactionId: nil, - in: thread + threadId: thread.id, + threadVariant: thread.variant ) } catch {} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift index e1ac4b392..06780bcaf 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Decryption.swift @@ -3,12 +3,10 @@ import Foundation import GRDB import Sodium -import CryptoSwift -import Curve25519Kit import SessionUtilitiesKit extension MessageReceiver { - internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: Box.KeyPair, dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: KeyPair, dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { let recipientX25519PrivateKey = x25519KeyPair.secretKey let recipientX25519PublicKey = x25519KeyPair.publicKey let signatureSize = dependencies.sign.Bytes @@ -46,7 +44,7 @@ extension MessageReceiver { return (Data(plaintext), SessionId(.standard, publicKey: senderX25519PublicKey).hexString) } - internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: Box.KeyPair, using dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { + internal static func decryptWithSessionBlindingProtocol(data: Data, isOutgoing: Bool, otherBlindedPublicKey: String, with openGroupPublicKey: String, userEd25519KeyPair: KeyPair, using dependencies: SMKDependencies = SMKDependencies()) throws -> (plaintext: Data, senderX25519PublicKey: String) { /// Ensure the data is at least long enough to have the required components guard data.count > (dependencies.nonceGenerator24.NonceBytes + 2), diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 20aa57cdc..5eec855eb 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -3,7 +3,6 @@ import Foundation import GRDB import Sodium -import SignalCoreKit import SessionUtilitiesKit import SessionSnodeKit @@ -20,7 +19,7 @@ public enum MessageReceiver { isOutgoing: Bool? = nil, otherBlindedPublicKey: String? = nil, dependencies: SMKDependencies = SMKDependencies() - ) throws -> (Message, SNProtoContent, String) { + ) throws -> (Message, SNProtoContent, String, SessionThread.Variant) { let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) let isOpenGroupMessage: Bool = (openGroupId != nil) @@ -40,20 +39,20 @@ public enum MessageReceiver { // Default to 'standard' as the old code didn't seem to require an `envelope.source` switch (SessionId.Prefix(from: envelope.source) ?? .standard) { case .standard, .unblinded: - guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else { + guard let userX25519KeyPair: KeyPair = Identity.fetchUserKeyPair(db) else { throw MessageReceiverError.noUserX25519KeyPair } (plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair) - case .blinded: + case .blinded15, .blinded25: 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 { + guard let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw MessageReceiverError.noUserED25519KeyPair } @@ -75,7 +74,9 @@ public enum MessageReceiver { throw MessageReceiverError.invalidGroupPublicKey } guard - let encryptionKeyPairs: [ClosedGroupKeyPair] = try? closedGroup.keyPairs.order(ClosedGroupKeyPair.Columns.receivedTimestamp.desc).fetchAll(db), + let encryptionKeyPairs: [ClosedGroupKeyPair] = try? closedGroup.keyPairs + .order(ClosedGroupKeyPair.Columns.receivedTimestamp.desc) + .fetchAll(db), !encryptionKeyPairs.isEmpty else { throw MessageReceiverError.noGroupKeyPair @@ -92,7 +93,7 @@ public enum MessageReceiver { do { return try decryptWithSessionProtocol( ciphertext: ciphertext, - using: Box.KeyPair( + using: KeyPair( publicKey: keyPair.publicKey.bytes, secretKey: keyPair.secretKey.bytes ) @@ -146,7 +147,6 @@ public enum MessageReceiver { message.recipient = userPublicKey message.sentTimestamp = envelope.timestamp message.receivedTimestamp = UInt64(SnodeAPI.currentOffsetTimestampMs()) - message.groupPublicKey = groupPublicKey message.openGroupServerMessageId = openGroupMessageServerId.map { UInt64($0) } // Validate @@ -160,30 +160,47 @@ public enum MessageReceiver { } // Extract the proper threadId for the message - let threadId: String = { - if let groupPublicKey: String = groupPublicKey { return groupPublicKey } - if let openGroupId: String = openGroupId { return openGroupId } + let (threadId, threadVariant): (String, SessionThread.Variant) = { + if let groupPublicKey: String = groupPublicKey { return (groupPublicKey, .legacyGroup) } + if let openGroupId: String = openGroupId { return (openGroupId, .community) } switch message { - case let message as VisibleMessage: return (message.syncTarget ?? sender) - case let message as ExpirationTimerUpdate: return (message.syncTarget ?? sender) - default: return sender + case let message as VisibleMessage: return ((message.syncTarget ?? sender), .contact) + case let message as ExpirationTimerUpdate: return ((message.syncTarget ?? sender), .contact) + default: return (sender, .contact) } }() - return (message, proto, threadId) + return (message, proto, threadId, threadVariant) } // MARK: - Handling public static func handle( _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, message: Message, serverExpirationTimestamp: TimeInterval?, associatedWithProto proto: SNProtoContent, - openGroupId: String?, dependencies: SMKDependencies = SMKDependencies() ) throws { + // Check if the message requires an existing conversation (if it does and the conversation isn't in + // the config then the message will be dropped) + guard + !Message.requiresExistingConversation(message: message, threadVariant: threadVariant) || + SessionUtil.conversationInConfig(db, threadId: threadId, threadVariant: threadVariant, visibleOnly: false) + else { throw MessageReceiverError.requiredThreadNotInConfig } + + // Throw if the message is outdated and shouldn't be processed + try throwIfMessageOutdated( + db, + message: message, + threadId: threadId, + threadVariant: threadVariant, + dependencies: dependencies + ) + switch message { case let message as ReadReceipt: try MessageReceiver.handleReadReceipt( @@ -193,63 +210,123 @@ public enum MessageReceiver { ) case let message as TypingIndicator: - try MessageReceiver.handleTypingIndicator(db, message: message) + try MessageReceiver.handleTypingIndicator( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) case let message as ClosedGroupControlMessage: - try MessageReceiver.handleClosedGroupControlMessage(db, message) + try MessageReceiver.handleClosedGroupControlMessage( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) case let message as DataExtractionNotification: - try MessageReceiver.handleDataExtractionNotification(db, message: message) + try MessageReceiver.handleDataExtractionNotification( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) case let message as ExpirationTimerUpdate: - try MessageReceiver.handleExpirationTimerUpdate(db, message: message) + try MessageReceiver.handleExpirationTimerUpdate( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) case let message as ConfigurationMessage: - try MessageReceiver.handleConfigurationMessage(db, message: message) + try MessageReceiver.handleLegacyConfigurationMessage(db, message: message) case let message as UnsendRequest: - try MessageReceiver.handleUnsendRequest(db, message: message) + try MessageReceiver.handleUnsendRequest( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) case let message as CallMessage: - try MessageReceiver.handleCallMessage(db, message: message) + try MessageReceiver.handleCallMessage( + db, + threadId: threadId, + threadVariant: threadVariant, + message: message + ) case let message as MessageRequestResponse: - try MessageReceiver.handleMessageRequestResponse(db, message: message, dependencies: dependencies) + try MessageReceiver.handleMessageRequestResponse( + db, + message: message, + dependencies: dependencies + ) case let message as VisibleMessage: try MessageReceiver.handleVisibleMessage( db, + threadId: threadId, + threadVariant: threadVariant, message: message, - associatedWithProto: proto, - openGroupId: openGroupId + associatedWithProto: proto ) + // SharedConfigMessages should be handled by the 'SharedUtil' instead of this + case is SharedConfigMessage: throw MessageReceiverError.invalidSharedConfigMessageHandling + default: fatalError() } // Perform any required post-handling logic - try MessageReceiver.postHandleMessage(db, message: message, openGroupId: openGroupId) + try MessageReceiver.postHandleMessage(db, threadId: threadId, message: message) } public static func postHandleMessage( _ db: Database, - message: Message, - openGroupId: String? + threadId: String, + message: Message ) throws { - // When handling any non-typing indicator message we want to make sure the thread becomes + // When handling any message type which has related UI 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 ReadReceipt: break case is TypingIndicator: break + case is ConfigurationMessage: break + case is UnsendRequest: break - default: - guard let threadInfo: (id: String, variant: SessionThread.Variant) = threadInfo(db, message: message, openGroupId: openGroupId) else { - return + case let message as ClosedGroupControlMessage: + // Only re-show a legacy group conversation if we are going to add a control text message + switch message.kind { + case .new, .encryptionKeyPair, .encryptionKeyPairRequest: return + default: break } - _ = try SessionThread - .fetchOrCreate(db, id: threadInfo.id, variant: threadInfo.variant) - .with(shouldBeVisible: true) - .saved(db) + fallthrough + + default: + // Only update the `shouldBeVisible` flag if the thread is currently not visible + // as we don't want to trigger a config update if not needed + let isCurrentlyVisible: Bool = try SessionThread + .filter(id: threadId) + .select(.shouldBeVisible) + .asRequest(of: Bool.self) + .fetchOne(db) + .defaulting(to: false) + + guard !isCurrentlyVisible else { return } + + try SessionThread + .filter(id: threadId) + .updateAllAndConfig( + db, + SessionThread.Columns.shouldBeVisible.set(to: true), + SessionThread.Columns.pinnedPriority.set(to: SessionUtil.visiblePriority) + ) } } @@ -278,111 +355,43 @@ public enum MessageReceiver { } } - // 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( + public static func throwIfMessageOutdated( _ db: Database, - publicKey: String, - name: String?, - profilePictureUrl: String?, - profileKey: OWSAES256Key?, - sentTimestamp: TimeInterval, - dependencies: Dependencies = Dependencies() + message: Message, + threadId: String, + threadVariant: SessionThread.Variant, + dependencies: SMKDependencies = SMKDependencies() ) throws { - let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies)) - let profile: Profile = Profile.fetchOrCreate(id: publicKey) - var updatedProfile: Profile = profile + switch message { + case is ReadReceipt: return // No visible artifact created so better to keep for more reliable read states + case is UnsendRequest: return // We should always process the removal of messages just in case + default: break + } - // Name - if let name = name, !name.isEmpty, name != profile.name { - let shouldUpdate: Bool - if isCurrentUser { - shouldUpdate = UserDefaults.standard[.lastDisplayNameUpdate] - .map { sentTimestamp > $0.timeIntervalSince1970 } - .defaulting(to: true) - } - else { - shouldUpdate = true - } - - if shouldUpdate { - if isCurrentUser { - UserDefaults.standard[.lastDisplayNameUpdate] = Date(timeIntervalSince1970: sentTimestamp) + // Determine the state of the conversation and the validity of the message + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let conversationVisibleInConfig: Bool = SessionUtil.conversationInConfig( + db, + threadId: threadId, + threadVariant: threadVariant, + visibleOnly: true + ) + let canPerformChange: Bool = SessionUtil.canPerformChange( + db, + threadId: threadId, + targetConfig: { + switch threadVariant { + case .contact: return (threadId == currentUserPublicKey ? .userProfile : .contacts) + default: return .userGroups } - - updatedProfile = updatedProfile.with(name: name) - } - } + }(), + changeTimestampMs: (message.sentTimestamp.map { Int64($0) } ?? SnodeAPI.currentOffsetTimestampMs()) + ) - // 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 = UserDefaults.standard[.lastProfilePictureUpdate] - .map { 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) - ) - } - } + // If the thread is visible or the message was sent more recently than the last config message (minus + // buffer period) then we should process the message, if not then throw as the message is outdated + guard !conversationVisibleInConfig && !canPerformChange else { return } - // 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.afterNextTransaction { _ in - ProfileManager.downloadAvatar(for: updatedProfile) - } - } + throw MessageReceiverError.outdatedMessage } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index 895366363..f0e139d35 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -1,29 +1,21 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SessionUtilitiesKit extension MessageSender { // MARK: - Durable - public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread, isSyncMessage: Bool = false) 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), - isSyncMessage: isSyncMessage - ) - } - - public static func send(_ db: Database, interaction: Interaction, in thread: SessionThread, isSyncMessage: Bool = false) throws { + public static func send( + _ db: Database, + interaction: Interaction, + threadId: String, + threadVariant: SessionThread.Variant, + isSyncMessage: Bool = false + ) 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 } @@ -31,25 +23,39 @@ extension MessageSender { send( db, message: VisibleMessage.from(db, interaction: interaction), - threadId: thread.id, + threadId: threadId, interactionId: interactionId, - to: try Message.Destination.from(db, thread: thread), + to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), isSyncMessage: isSyncMessage ) } - public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread, isSyncMessage: Bool = false) throws { + public static func send( + _ db: Database, + message: Message, + interactionId: Int64?, + threadId: String, + threadVariant: SessionThread.Variant, + isSyncMessage: Bool = false + ) throws { send( db, message: message, - threadId: thread.id, + threadId: threadId, interactionId: interactionId, - to: try Message.Destination.from(db, thread: thread), + to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), isSyncMessage: isSyncMessage ) } - public static func send(_ db: Database, message: Message, threadId: String?, interactionId: Int64?, to destination: Message.Destination, isSyncMessage: Bool = false) { + public static func send( + _ db: Database, + message: Message, + threadId: String?, + interactionId: Int64?, + to destination: Message.Destination, + isSyncMessage: Bool = false + ) { // If it's a sync message then we need to make some slight tweaks before sending so use the proper // sync message sending process instead of the standard process guard !isSyncMessage else { @@ -81,164 +87,95 @@ extension MessageSender { // MARK: - Non-Durable - public static func sendNonDurably(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws -> Promise { - guard let interactionId: Int64 = interaction.id else { return Promise(error: 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 { + public static func preparedSendData( + _ db: Database, + interaction: Interaction, + threadId: String, + threadVariant: SessionThread.Variant + ) throws -> PreparedSendData { // 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( + + return try MessageSender.preparedSendData( db, message: VisibleMessage.from(db, interaction: interaction), - interactionId: interactionId, - to: try Message.Destination.from(db, thread: thread) + to: try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant), + namespace: try Message.Destination + .from(db, threadId: threadId, threadVariant: threadVariant) + .defaultNamespace, + interactionId: interactionId ) } - 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 - } + public static func performUploadsIfNeeded(preparedSendData: PreparedSendData) -> AnyPublisher { + // We need an interactionId in order for a message to have uploads + guard let interactionId: Int64 = preparedSendData.interactionId else { + return Just(preparedSendData) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - - // 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) } + + let threadId: String = { + switch preparedSendData.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) - 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, - isSyncMessage: false - ) - } + case .openGroupInbox(_, _, let blindedPublicKey): return blindedPublicKey } - } - - /// 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 publicKey: String = getUserHexEncodedPublicKey(db) - let destination: Message.Destination = Message.Destination.contact(publicKey: publicKey) - let configurationMessage = try ConfigurationMessage.getCurrent(db) - let (promise, seal) = Promise.pending() - - if forceSyncNow { - try MessageSender - .sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil, isSyncMessage: false) - .done { seal.fulfill(()) } - .catch { _ in seal.reject(StorageError.generic) } - .retainUntilComplete() - } - else { - JobRunner.add( - db, - job: Job( - variant: .messageSend, - threadId: publicKey, - details: MessageSendJob.Details( - destination: destination, - message: configurationMessage - ) + return Storage.shared + .readPublisher { db -> (attachments: [Attachment], openGroup: OpenGroup?) in + let attachmentStateInfo: [Attachment.StateInfo] = (try? Attachment + .stateInfo(interactionId: interactionId, state: .uploading) + .fetchAll(db)) + .defaulting(to: []) + + // If there is no attachment data then just return early + guard !attachmentStateInfo.isEmpty else { return ([], nil) } + + // Otherwise fetch the open group (if there is one) + return ( + (try? Attachment + .filter(ids: attachmentStateInfo.map { $0.attachmentId }) + .fetchAll(db)) + .defaulting(to: []), + try? OpenGroup.fetchOne(db, id: threadId) ) - ) - seal.fulfill(()) - } - - return promise + } + .flatMap { attachments, openGroup -> AnyPublisher<[String?], Error> in + guard !attachments.isEmpty else { + return Just<[String?]>([]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return Publishers + .MergeMany( + attachments + .map { attachment -> AnyPublisher in + attachment + .upload( + to: ( + openGroup.map { Attachment.Destination.openGroup($0) } ?? + .fileServer + ) + ) + } + ) + .collect() + .eraseToAnyPublisher() + } + .map { results -> PreparedSendData in + // Once the attachments are processed then update the PreparedSendData with + // the fileIds associated to the message + let fileIds: [String] = results.compactMap { result -> String? in result } + + return preparedSendData.with(fileIds: fileIds) + } + .eraseToAnyPublisher() } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift index 99f4e6765..a9d4dca47 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Encryption.swift @@ -1,16 +1,18 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import Sodium import SessionUtilitiesKit extension MessageSender { internal static func encryptWithSessionProtocol( - _ plaintext: Data, + _ db: Database, + 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 { + guard let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw MessageSenderError.noUserED25519KeyPair } @@ -30,13 +32,17 @@ extension MessageSender { } internal static func encryptWithSessionBlindingProtocol( - _ plaintext: Data, + _ db: Database, + 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 { + guard + SessionId.Prefix(from: recipientBlindedId) == .blinded15 || + SessionId.Prefix(from: recipientBlindedId) == .blinded25 + else { throw MessageSenderError.signingFailed } + guard let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { throw MessageSenderError.noUserED25519KeyPair } guard let blindedKeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroupPublicKey, edKeyPair: userEd25519KeyPair, genericHash: dependencies.genericHash) else { diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 8f34c9467..a40c3a426 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -1,85 +1,207 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SessionSnodeKit import SessionUtilitiesKit import Sodium public final class MessageSender { - // MARK: - Preparation + // MARK: - Message Preparation - 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) - } - } - - // MARK: - Convenience - - public static func sendImmediate( - _ db: Database, - message: Message, - to destination: Message.Destination, - interactionId: Int64?, - isSyncMessage: Bool - ) throws -> Promise { - switch destination { - case .contact, .closedGroup: - return try sendToSnodeDestination(db, message: message, to: destination, interactionId: interactionId, isSyncMessage: isSyncMessage) - - 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( - _ db: Database, - message: Message, - to destination: Message.Destination, - interactionId: Int64?, - isSyncMessage: Bool = false - ) throws -> Promise { - let (promise, seal) = Promise.pending() - let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) - let messageSendTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() + public struct PreparedSendData { + let shouldSend: Bool + let destination: Message.Destination + let namespace: SnodeAPI.Namespace? - // Set the timestamp, sender and recipient - message.sentTimestamp = ( - message.sentTimestamp ?? // Visible messages will already have their sent timestamp set + let message: Message? + let interactionId: Int64? + let isSyncMessage: Bool? + let totalAttachmentsUploaded: Int + + let snodeMessage: SnodeMessage? + let plaintext: Data? + let ciphertext: Data? + + private init( + shouldSend: Bool, + message: Message?, + destination: Message.Destination, + namespace: SnodeAPI.Namespace?, + interactionId: Int64?, + isSyncMessage: Bool?, + totalAttachmentsUploaded: Int = 0, + snodeMessage: SnodeMessage?, + plaintext: Data?, + ciphertext: Data? + ) { + self.shouldSend = shouldSend + + self.message = message + self.destination = destination + self.namespace = namespace + self.interactionId = interactionId + self.isSyncMessage = isSyncMessage + self.totalAttachmentsUploaded = totalAttachmentsUploaded + + self.snodeMessage = snodeMessage + self.plaintext = plaintext + self.ciphertext = ciphertext + } + + /// This should be used to send a message to one-to-one or closed group conversations + fileprivate init( + message: Message, + destination: Message.Destination, + namespace: SnodeAPI.Namespace, + interactionId: Int64?, + isSyncMessage: Bool?, + snodeMessage: SnodeMessage + ) { + self.shouldSend = true + + self.message = message + self.destination = destination + self.namespace = namespace + self.interactionId = interactionId + self.isSyncMessage = isSyncMessage + self.totalAttachmentsUploaded = 0 + + self.snodeMessage = snodeMessage + self.plaintext = nil + self.ciphertext = nil + } + + /// This should be used to send a message to open group conversations + fileprivate init( + message: Message, + destination: Message.Destination, + interactionId: Int64?, + plaintext: Data + ) { + self.shouldSend = true + + self.message = message + self.destination = destination + self.namespace = nil + self.interactionId = interactionId + self.isSyncMessage = false + self.totalAttachmentsUploaded = 0 + + self.snodeMessage = nil + self.plaintext = plaintext + self.ciphertext = nil + } + + /// This should be used to send a message to an open group inbox + fileprivate init( + message: Message, + destination: Message.Destination, + interactionId: Int64?, + ciphertext: Data + ) { + self.shouldSend = true + + self.message = message + self.destination = destination + self.namespace = nil + self.interactionId = interactionId + self.isSyncMessage = false + self.totalAttachmentsUploaded = 0 + + self.snodeMessage = nil + self.plaintext = nil + self.ciphertext = ciphertext + } + + // MARK: - Mutation + + internal func with(fileIds: [String]) -> PreparedSendData { + return PreparedSendData( + shouldSend: shouldSend, + message: message, + destination: destination.with(fileIds: fileIds), + namespace: namespace, + interactionId: interactionId, + isSyncMessage: isSyncMessage, + totalAttachmentsUploaded: fileIds.count, + snodeMessage: snodeMessage, + plaintext: plaintext, + ciphertext: ciphertext + ) + } + } + + public static func preparedSendData( + _ db: Database, + message: Message, + to destination: Message.Destination, + namespace: SnodeAPI.Namespace?, + interactionId: Int64?, + isSyncMessage: Bool = false, + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + // Common logic for all destinations + let currentUserPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let messageSendTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs() + let updatedMessage: Message = message + + // Set the message 'sentTimestamp' (Visible messages will already have their sent timestamp set) + updatedMessage.sentTimestamp = ( + updatedMessage.sentTimestamp ?? UInt64(messageSendTimestamp) ) - message.sender = currentUserPublicKey + + switch destination { + case .contact, .closedGroup: + return try prepareSendToSnodeDestination( + db, + message: updatedMessage, + to: destination, + namespace: namespace, + interactionId: interactionId, + userPublicKey: currentUserPublicKey, + messageSendTimestamp: messageSendTimestamp, + isSyncMessage: isSyncMessage, + using: dependencies + ) + + case .openGroup: + return try prepareSendToOpenGroupDestination( + db, + message: updatedMessage, + to: destination, + interactionId: interactionId, + messageSendTimestamp: messageSendTimestamp, + using: dependencies + ) + + case .openGroupInbox: + return try prepareSendToOpenGroupInboxDestination( + db, + message: message, + to: destination, + interactionId: interactionId, + userPublicKey: currentUserPublicKey, + messageSendTimestamp: messageSendTimestamp, + using: dependencies + ) + } + } + + internal static func prepareSendToSnodeDestination( + _ db: Database, + message: Message, + to destination: Message.Destination, + namespace: SnodeAPI.Namespace?, + interactionId: Int64?, + userPublicKey: String, + messageSendTimestamp: Int64, + isSyncMessage: Bool = false, + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + message.sender = userPublicKey message.recipient = { switch destination { case .contact(let publicKey): return publicKey @@ -88,23 +210,25 @@ public final class MessageSender { } }() - // 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, isSyncMessage: isSyncMessage) - seal.reject(error) - } - // Validate the message - guard message.isValid else { - handleFailure(db, with: .invalidMessage) - return promise + guard message.isValid, let namespace: SnodeAPI.Namespace = namespace else { + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .invalidMessage, + interactionId: interactionId, + using: dependencies + ) } - // Attach the user's profile if needed - if !isSyncMessage, var messageWithProfile: MessageWithProfile = message as? MessageWithProfile { + // Attach the user's profile if needed (no need to do so for 'Note to Self' or sync + // messages as they will be managed by the user config handling + let isSelfSend: Bool = (message.recipient == userPublicKey) + + if !isSelfSend, !isSyncMessage, var messageWithProfile: MessageWithProfile = message as? MessageWithProfile { let profile: Profile = Profile.fetchOrCreateCurrentUser(db) - if let profileKey: Data = profile.profileEncryptionKey?.keyData, let profilePictureUrl: String = profile.profilePictureUrl { + if let profileKey: Data = profile.profileEncryptionKey, let profilePictureUrl: String = profile.profilePictureUrl { messageWithProfile.profile = VisibleMessage.VMProfile( displayName: profile.name, profileKey: profileKey, @@ -121,8 +245,13 @@ public final class MessageSender { // Convert it to protobuf guard let proto = message.toProto(db) else { - handleFailure(db, with: .protoConversionFailed) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .protoConversionFailed, + interactionId: interactionId, + using: dependencies + ) } // Serialize the protobuf @@ -133,8 +262,13 @@ public final class MessageSender { } catch { SNLog("Couldn't serialize proto due to error: \(error).") - handleFailure(db, with: .other(error)) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .other(error), + interactionId: interactionId, + using: dependencies + ) } // Encrypt the serialized protobuf @@ -142,7 +276,7 @@ public final class MessageSender { do { switch destination { case .contact(let publicKey): - ciphertext = try encryptWithSessionProtocol(plaintext, for: publicKey) + ciphertext = try encryptWithSessionProtocol(db, plaintext: plaintext, for: publicKey) case .closedGroup(let groupPublicKey): guard let encryptionKeyPair: ClosedGroupKeyPair = try? ClosedGroupKeyPair.fetchLatestKeyPair(db, threadId: groupPublicKey) else { @@ -150,7 +284,8 @@ public final class MessageSender { } ciphertext = try encryptWithSessionProtocol( - plaintext, + db, + plaintext: plaintext, for: SessionId(.standard, publicKey: encryptionKeyPair.publicKey.bytes).hexString ) @@ -159,8 +294,13 @@ public final class MessageSender { } catch { SNLog("Couldn't encrypt message for destination: \(destination) due to error: \(error).") - handleFailure(db, with: .other(error)) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .other(error), + interactionId: interactionId, + using: dependencies + ) } // Wrap the result @@ -181,18 +321,27 @@ public final class MessageSender { let wrappedMessage: Data do { - wrappedMessage = try MessageWrapper.wrap(type: kind, timestamp: message.sentTimestamp!, - senderPublicKey: senderPublicKey, base64EncodedContent: ciphertext.base64EncodedString()) + wrappedMessage = try MessageWrapper.wrap( + type: kind, + timestamp: message.sentTimestamp!, + senderPublicKey: senderPublicKey, + base64EncodedContent: ciphertext.base64EncodedString() + ) } catch { SNLog("Couldn't wrap message due to error: \(error).") - handleFailure(db, with: .other(error)) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .other(error), + interactionId: interactionId, + using: dependencies + ) } // Send the result let base64EncodedData = wrappedMessage.base64EncodedString() - + let snodeMessage = SnodeMessage( recipient: message.recipient!, data: base64EncodedData, @@ -200,133 +349,30 @@ public final class MessageSender { timestampMs: UInt64(messageSendTimestamp) ) - 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 = { - // Don't send a notification when sending messages in 'Note to Self' - guard message.recipient != currentUserPublicKey else { return false } - - 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) - ) - let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]) - .defaulting(to: false) - - 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(()) - } - } - } - $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: .default)) { error in - SNLog("Couldn't send message due to error: \(error).") - - Storage.shared.read { db in - handleFailure(db, with: .other(error)) - } - } - - return promise + return PreparedSendData( + message: message, + destination: destination, + namespace: namespace, + interactionId: interactionId, + isSyncMessage: isSyncMessage, + snodeMessage: snodeMessage + ) } - - // MARK: Open Groups - internal static func sendToOpenGroupDestination( + internal static func prepareSendToOpenGroupDestination( _ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?, - dependencies: SMKDependencies = SMKDependencies() - ) -> Promise { - let (promise, seal) = Promise.pending() - - // Set the timestamp, sender and recipient - if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set - message.sentTimestamp = UInt64(SnodeAPI.currentOffsetTimestampMs()) - } + messageSendTimestamp: Int64, + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + let threadId: String switch destination { 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, @@ -341,30 +387,48 @@ public final class MessageSender { // 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 - case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, let fileIds) = destination + let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId), + let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), + case .openGroup(_, let server, _, _, _) = destination else { - seal.reject(MessageSenderError.invalidMessage) - return promise + throw MessageSenderError.invalidMessage } - // 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) - } + 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.isEmpty || capabilities.contains(.blind) else { + return SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString + } + guard let blindedKeyPair: KeyPair = dependencies.sodium.blindedKeyPair(serverPublicKey: openGroup.publicKey, edKeyPair: userEdKeyPair, genericHash: dependencies.genericHash) else { + preconditionFailure() + } + + return SessionId(.blinded15, publicKey: blindedKeyPair.publicKey).hexString + }() // Validate the message - guard let message = message as? VisibleMessage else { + guard + let message = message as? VisibleMessage, + message.isValid + else { #if DEBUG - preconditionFailure() - #else - handleFailure(db, with: MessageSenderError.invalidMessage) - return promise + if (message as? VisibleMessage) == nil { preconditionFailure() } #endif - } - guard message.isValid else { - handleFailure(db, with: .invalidMessage) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .invalidMessage, + interactionId: interactionId, + using: dependencies + ) } // Attach the user's profile @@ -373,8 +437,13 @@ public final class MessageSender { ) if (message.profile?.displayName ?? "").isEmpty { - handleFailure(db, with: .noUsername) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .noUsername, + interactionId: interactionId, + using: dependencies + ) } // Perform any pre-send actions @@ -382,8 +451,13 @@ public final class MessageSender { // Convert it to protobuf guard let proto = message.toProto(db) else { - handleFailure(db, with: .protoConversionFailed) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .protoConversionFailed, + interactionId: interactionId, + using: dependencies + ) } // Serialize the protobuf @@ -394,78 +468,44 @@ public final class MessageSender { } catch { SNLog("Couldn't serialize proto due to error: \(error).") - handleFailure(db, with: .other(error)) - return promise - } - - // Send the result - OpenGroupAPI - .send( + throw MessageSender.handleFailedMessageSend( db, - plaintext: plaintext, - to: roomToken, - on: server, - whisperTo: whisperTo, - whisperMods: whisperMods, - fileIds: fileIds, + message: message, + with: .other(error), + interactionId: interactionId, using: dependencies ) - .done(on: DispatchQueue.global(qos: .default)) { responseInfo, data in - message.openGroupServerMessageId = UInt64(data.id) - let serverTimestampMs: UInt64? = data.posted.map { UInt64(floor($0 * 1000)) } - - 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: serverTimestampMs - ) - seal.fulfill(()) - } - } - .catch(on: DispatchQueue.global(qos: .default)) { error in - dependencies.storage.read { db in - handleFailure(db, with: .other(error)) - } - } + } - return promise + return PreparedSendData( + message: message, + destination: destination, + interactionId: interactionId, + plaintext: plaintext + ) } - - internal static func sendToOpenGroupInboxDestination( + + internal static func prepareSendToOpenGroupInboxDestination( _ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?, - dependencies: SMKDependencies = SMKDependencies() - ) -> Promise { - let (promise, seal) = Promise.pending() - - 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(SnodeAPI.currentOffsetTimestampMs()) + userPublicKey: String, + messageSendTimestamp: Int64, + using dependencies: SMKDependencies = SMKDependencies() + ) throws -> PreparedSendData { + guard case .openGroupInbox(_, let openGroupPublicKey, let recipientBlindedPublicKey) = destination else { + throw MessageSenderError.invalidMessage } + 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 { + if let profileKey: Data = profile.profileEncryptionKey, let profilePictureUrl: String = profile.profilePictureUrl { message.profile = VisibleMessage.VMProfile( displayName: profile.name, profileKey: profileKey, @@ -482,8 +522,13 @@ public final class MessageSender { // Convert it to protobuf guard let proto = message.toProto(db) else { - handleFailure(db, with: .protoConversionFailed) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .protoConversionFailed, + interactionId: interactionId, + using: dependencies + ) } // Serialize the protobuf @@ -494,8 +539,13 @@ public final class MessageSender { } catch { SNLog("Couldn't serialize proto due to error: \(error).") - handleFailure(db, with: .other(error)) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .other(error), + interactionId: interactionId, + using: dependencies + ) } // Encrypt the serialized protobuf @@ -503,7 +553,8 @@ public final class MessageSender { do { ciphertext = try encryptWithSessionBlindingProtocol( - plaintext, + db, + plaintext: plaintext, for: recipientBlindedPublicKey, openGroupPublicKey: openGroupPublicKey, using: dependencies @@ -511,39 +562,333 @@ public final class MessageSender { } catch { SNLog("Couldn't encrypt message for destination: \(destination) due to error: \(error).") - handleFailure(db, with: .other(error)) - return promise + throw MessageSender.handleFailedMessageSend( + db, + message: message, + with: .other(error), + interactionId: interactionId, + using: dependencies + ) + } + + return PreparedSendData( + message: message, + destination: destination, + interactionId: interactionId, + ciphertext: ciphertext + ) + } + + // MARK: - Sending + + public static func sendImmediate( + preparedSendData: PreparedSendData, + using dependencies: SMKDependencies = SMKDependencies() + ) -> AnyPublisher { + guard preparedSendData.shouldSend else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + // We now allow the creation of message data without validating it's attachments have finished + // uploading first, this is here to ensure we don't send a message which should have uploaded + // files + // + // If you see this error then you need to call + // `MessageSender.performUploadsIfNeeded(queue:preparedSendData:)` before calling this function + switch preparedSendData.message { + case let visibleMessage as VisibleMessage: + let expectedAttachmentUploadCount: Int = ( + visibleMessage.attachmentIds.count + + (visibleMessage.linkPreview?.attachmentId != nil ? 1 : 0) + + (visibleMessage.quote?.attachmentId != nil ? 1 : 0) + ) + + guard expectedAttachmentUploadCount == preparedSendData.totalAttachmentsUploaded else { + // Make sure to actually handle this as a failure (if we don't then the message + // won't go into an error state correctly) + if let message: Message = preparedSendData.message { + dependencies.storage.read { db in + MessageSender.handleFailedMessageSend( + db, + message: message, + with: .attachmentsNotUploaded, + interactionId: preparedSendData.interactionId, + isSyncMessage: (preparedSendData.isSyncMessage == true), + using: dependencies + ) + } + } + + return Fail(error: MessageSenderError.attachmentsNotUploaded) + .eraseToAnyPublisher() + } + + break + + default: break + } + + switch preparedSendData.destination { + case .contact, .closedGroup: return sendToSnodeDestination(data: preparedSendData, using: dependencies) + case .openGroup: return sendToOpenGroupDestination(data: preparedSendData, using: dependencies) + case .openGroupInbox: return sendToOpenGroupInbox(data: preparedSendData, using: dependencies) + } + } + + // MARK: - One-to-One + + private static func sendToSnodeDestination( + data: PreparedSendData, + using dependencies: SMKDependencies = SMKDependencies() + ) -> AnyPublisher { + guard + let message: Message = data.message, + let namespace: SnodeAPI.Namespace = data.namespace, + let isSyncMessage: Bool = data.isSyncMessage, + let snodeMessage: SnodeMessage = data.snodeMessage + else { + return Fail(error: MessageSenderError.invalidMessage) + .eraseToAnyPublisher() + } + + return SnodeAPI + .sendMessage( + snodeMessage, + in: namespace + ) + .flatMap { response -> AnyPublisher in + let updatedMessage: Message = message + updatedMessage.serverHash = response.1.hash + + let job: Job? = Job( + variant: .notifyPushServer, + behaviour: .runOnce, + details: NotifyPushServerJob.Details(message: snodeMessage) + ) + let shouldNotify: Bool = { + switch updatedMessage { + case is VisibleMessage, is UnsendRequest: return !isSyncMessage + case let callMessage as CallMessage: + // Note: Other 'CallMessage' types are too big to send as push notifications + // so only send the 'preOffer' message as a notification + switch callMessage.kind { + case .preOffer: return true + default: return false + } + + default: return false + } + }() + + return dependencies.storage + .writePublisher { db -> Void in + try MessageSender.handleSuccessfulMessageSend( + db, + message: updatedMessage, + to: data.destination, + interactionId: data.interactionId, + isSyncMessage: isSyncMessage, + using: dependencies + ) + + guard shouldNotify else { return () } + + JobRunner.add(db, job: job) + return () + } + .flatMap { _ -> AnyPublisher in + let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]) + .defaulting(to: false) + + guard shouldNotify && !isMainAppActive else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + guard let job: Job = job else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return Deferred { + Future { resolver in + NotifyPushServerJob.run( + job, + queue: .global(qos: .default), + success: { _, _, _ in resolver(Result.success(())) }, + failure: { _, _, _, _ in + // Always fulfill because the notify PN server job isn't critical. + resolver(Result.success(())) + }, + deferred: { _, _ in + // Always fulfill because the notify PN server job isn't critical. + resolver(Result.success(())) + } + ) + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + SNLog("Couldn't send message due to error: \(error).") + + dependencies.storage.read { db in + MessageSender.handleFailedMessageSend( + db, + message: message, + with: .other(error), + interactionId: data.interactionId, + using: dependencies + ) + } + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() + } + + // MARK: - Open Groups + + private static func sendToOpenGroupDestination( + data: PreparedSendData, + using dependencies: SMKDependencies = SMKDependencies() + ) -> AnyPublisher { + guard + let message: Message = data.message, + case .openGroup(let roomToken, let server, let whisperTo, let whisperMods, let fileIds) = data.destination, + let plaintext: Data = data.plaintext + else { + return Fail(error: MessageSenderError.invalidMessage) + .eraseToAnyPublisher() } // 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) + return dependencies.storage + .readPublisher { db in + try OpenGroupAPI + .preparedSend( + db, + plaintext: plaintext, + to: roomToken, + on: server, + whisperTo: whisperTo, + whisperMods: whisperMods, + fileIds: fileIds, + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .flatMap { (responseInfo, responseData) -> AnyPublisher in + let serverTimestampMs: UInt64? = responseData.posted.map { UInt64(floor($0 * 1000)) } + let updatedMessage: Message = message + updatedMessage.openGroupServerMessageId = UInt64(responseData.id) - dependencies.storage.write { transaction in + return dependencies.storage.writePublisher { 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 + message: updatedMessage, + to: data.destination, + interactionId: data.interactionId, + serverTimestampMs: serverTimestampMs, + using: dependencies ) - seal.fulfill(()) + + return () } } - .catch(on: DispatchQueue.global(qos: .default)) { error in - dependencies.storage.read { db in - handleFailure(db, with: .other(error)) + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + dependencies.storage.read { db in + MessageSender.handleFailedMessageSend( + db, + message: message, + with: .other(error), + interactionId: data.interactionId, + using: dependencies + ) + } + } } - } + ) + .eraseToAnyPublisher() + } + + private static func sendToOpenGroupInbox( + data: PreparedSendData, + using dependencies: SMKDependencies = SMKDependencies() + ) -> AnyPublisher { + guard + let message: Message = data.message, + case .openGroupInbox(let server, _, let recipientBlindedPublicKey) = data.destination, + let ciphertext: Data = data.ciphertext + else { + return Fail(error: MessageSenderError.invalidMessage) + .eraseToAnyPublisher() + } - return promise + // Send the result + return dependencies.storage + .readPublisher { db in + try OpenGroupAPI + .preparedSend( + db, + ciphertext: ciphertext, + toInboxFor: recipientBlindedPublicKey, + on: server, + using: dependencies + ) + } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .flatMap { (responseInfo, responseData) -> AnyPublisher in + let updatedMessage: Message = message + updatedMessage.openGroupServerMessageId = UInt64(responseData.id) + + return dependencies.storage.writePublisher { 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: updatedMessage, + to: data.destination, + interactionId: data.interactionId, + serverTimestampMs: UInt64(floor(responseData.posted * 1000)), + using: dependencies + ) + + return () + } + } + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + dependencies.storage.read { db in + MessageSender.handleFailedMessageSend( + db, + message: message, + with: .other(error), + interactionId: data.interactionId, + using: dependencies + ) + } + } + } + ) + .eraseToAnyPublisher() } // MARK: Success & Failure Handling @@ -580,7 +925,8 @@ public final class MessageSender { to destination: Message.Destination, interactionId: Int64?, serverTimestampMs: UInt64? = nil, - isSyncMessage: Bool = false + isSyncMessage: Bool = false, + using dependencies: SMKDependencies = SMKDependencies() ) throws { // If the message was a reaction then we want to update the reaction instead of the original // interaction (which the 'interactionId' is pointing to @@ -629,16 +975,8 @@ public final class MessageSender { } } - 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 - } - }() + // Extract the threadId from the message + let threadId: String = Message.threadId(forMessage: message, destination: destination) // Prevent ControlMessages from being handled multiple times if not supported try? ControlMessageProcessRecord( @@ -649,7 +987,7 @@ public final class MessageSender { ControlMessageProcessRecord.defaultExpirationSeconds ) )?.insert(db) - + // Sync the message if needed scheduleSyncMessageIfNeeded( db, @@ -661,17 +999,17 @@ public final class MessageSender { ) } - public static func handleFailedMessageSend( + @discardableResult internal static func handleFailedMessageSend( _ db: Database, message: Message, with error: MessageSenderError, interactionId: Int64?, - isSyncMessage: Bool = false - ) { - // TODO: Revert the local database change + isSyncMessage: Bool = false, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Error { // If the message was a reaction then we don't want to do anything to the original - // interaction (which the 'interactionId' is pointing to - guard (message as? VisibleMessage)?.reaction == nil else { return } + // interaciton (which the 'interactionId' is pointing to + guard (message as? VisibleMessage)?.reaction == nil else { return error } // Check if we need to mark any "sending" recipients as "failed" // @@ -691,12 +1029,12 @@ public final class MessageSender { .fetchAll(db)) .defaulting(to: []) - guard !rowIds.isEmpty else { return } + guard !rowIds.isEmpty else { return error } // 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 + dependencies.storage.write { db in try RecipientState .filter(rowIds.contains(Column.rowID)) .updateAll( @@ -708,6 +1046,8 @@ public final class MessageSender { ) } } + + return error } // MARK: - Convenience diff --git a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift index 5c6ffe962..d5c5b48fa 100644 --- a/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/Notification+MessageReceiver.swift @@ -4,6 +4,7 @@ import Foundation public extension Notification.Name { + // FIXME: Remove once `useSharedUtilForUserConfig` is permanent static let initialConfigurationMessageReceived = Notification.Name("initialConfigurationMessageReceived") static let missedCall = Notification.Name("missedCall") } @@ -14,5 +15,6 @@ public extension Notification.Key { @objc public extension NSNotification { + // FIXME: Remove once `useSharedUtilForUserConfig` is permanent @objc static let initialConfigurationMessageReceived = Notification.Name.initialConfigurationMessageReceived.rawValue as NSString } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift index 51e2951ac..f946eb476 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift @@ -4,9 +4,9 @@ import Foundation import GRDB public protocol NotificationsProtocol { - func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) - func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) - func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) + func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) + func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) + func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) func cancelNotifications(identifiers: [String]) func clearAllNotifications() } diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 11499c28f..78886b8c8 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -1,12 +1,12 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +import Foundation +import Combine import GRDB -import PromiseKit import SessionSnodeKit import SessionUtilitiesKit -@objc(LKPushNotificationAPI) -public final class PushNotificationAPI : NSObject { +public enum PushNotificationAPI { struct RegistrationRequestBody: Codable { let token: String let pubKey: String? @@ -28,13 +28,14 @@ public final class PushNotificationAPI : NSObject { } // 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 maxRetryCount: Int = 4 private static let tokenExpirationInterval: TimeInterval = 12 * 60 * 60 - @objc public enum ClosedGroupOperation : Int { + public enum ClosedGroupOperation: Int { case subscribe, unsubscribe public var endpoint: String { @@ -44,174 +45,215 @@ public final class PushNotificationAPI : NSObject { } } } - - // MARK: - Initialization - private override init() { } - // MARK: - Registration - public static func unregister(_ token: Data) -> Promise { + public static func unregister(_ token: Data) -> AnyPublisher { let requestBody: RegistrationRequestBody = RegistrationRequestBody(token: token.toHexString(), pubKey: nil) guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) + return Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() } + // Unsubscribe from all closed groups (including ones the user is no longer a member of, + // just in case) + Storage.shared + .readPublisher { db -> (String, Set) in + ( + getUserHexEncodedPublicKey(db), + try ClosedGroup + .select(.threadId) + .asRequest(of: String.self) + .fetchSet(db) + ) + } + .flatMap { userPublicKey, closedGroupPublicKeys in + Publishers + .MergeMany( + closedGroupPublicKeys + .map { closedGroupPublicKey -> AnyPublisher in + PushNotificationAPI + .performOperation( + .unsubscribe, + for: closedGroupPublicKey, + publicKey: userPublicKey + ) + } + ) + .collect() + .eraseToAnyPublisher() + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() + + // Unregister for normal push notifications let url = URL(string: "\(server)/unregister")! var request: URLRequest = URLRequest(url: url) request.httpMethod = "POST" - request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] request.httpBody = body - let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - 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").") + return OnionRequestAPI + .sendOnionRequest(request, to: server, with: serverPublicKey) + .map { _, data -> Void 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").") + } + + return () + } + .retry(maxRetryCount) + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't unregister from push notifications.") } } - } - promise.catch2 { error in - SNLog("Couldn't unregister from push notifications.") - } - - // 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 + ) + .eraseToAnyPublisher() } - - @objc(unregisterToken:) - public static func objc_unregister(token: Data) -> AnyPromise { - return AnyPromise.from(unregister(token)) - } - - public static func register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> Promise { + + public static func register( + with token: Data, + publicKey: String, + isForcedUpdate: Bool + ) -> AnyPublisher { 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) + return Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() } - let userDefaults = UserDefaults.standard - let oldToken = userDefaults[.deviceToken] - let lastUploadTime = userDefaults[.lastDeviceTokenUpload] - let now = Date().timeIntervalSince1970 + let oldToken: String? = UserDefaults.standard[.deviceToken] + let lastUploadTime: Double = UserDefaults.standard[.lastDeviceTokenUpload] + let now: TimeInterval = Date().timeIntervalSince1970 + guard isForcedUpdate || hexEncodedToken != oldToken || now - lastUploadTime > tokenExpirationInterval else { SNLog("Device token hasn't changed or expired; no need to re-upload.") - return Promise { $0.fulfill(()) } + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } let url = URL(string: "\(server)/register")! var request: URLRequest = URLRequest(url: url) request.httpMethod = "POST" - request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.allHTTPHeaderFields = [ HTTPHeader.contentType: "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.") + return Publishers + .MergeMany( + [ + OnionRequestAPI + .sendOnionRequest(request, to: server, with: serverPublicKey) + .map { _, 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.standard[.deviceToken] = hexEncodedToken + UserDefaults.standard[.lastDeviceTokenUpload] = now + UserDefaults.standard[.isUsingFullAPNs] = true + return () } - 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 - } - } - ) - promises.first?.catch2 { error in - SNLog("Couldn't register device token.") - } - - // Subscribe to all closed groups - promises.append( - contentsOf: Storage.shared - .read { db -> [String] in - try ClosedGroup - .select(.threadId) - .joining( - required: ClosedGroup.members - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) + .retry(maxRetryCount) + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: SNLog("Couldn't register device token.") + } + } ) - .asRequest(of: String.self) - .fetchAll(db) - } - .defaulting(to: []) - .map { closedGroupPublicKey -> Promise in - performOperation(.subscribe, for: closedGroupPublicKey, publicKey: publicKey) - } - ) - - return when(fulfilled: promises) + .eraseToAnyPublisher() + ].appending( + 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 -> AnyPublisher in + PushNotificationAPI + .performOperation( + .subscribe, + for: closedGroupPublicKey, + publicKey: publicKey + ) + } + ) + ) + .collect() + .map { _ in () } + .eraseToAnyPublisher() } - @objc(registerWithToken:hexEncodedPublicKey:isForcedUpdate:) - public static func objc_register(with token: Data, publicKey: String, isForcedUpdate: Bool) -> AnyPromise { - return AnyPromise.from(register(with: token, publicKey: publicKey, isForcedUpdate: isForcedUpdate)) - } - - @discardableResult - public static func performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> Promise { + public static func performOperation( + _ operation: ClosedGroupOperation, + for closedGroupPublicKey: String, + publicKey: String + ) -> AnyPublisher { let isUsingFullAPNs = UserDefaults.standard[.isUsingFullAPNs] let requestBody: ClosedGroupRequestBody = ClosedGroupRequestBody( closedGroupPublicKey: closedGroupPublicKey, pubKey: publicKey ) - guard isUsingFullAPNs else { return Promise { $0.fulfill(()) } } + guard isUsingFullAPNs else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) + return Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() } let url = URL(string: "\(server)/\(operation.endpoint)")! var request: URLRequest = URLRequest(url: url) request.httpMethod = "POST" - request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.allHTTPHeaderFields = [ HTTPHeader.contentType: "application/json" ] request.httpBody = body - let promise: Promise = attempt(maxRetryCount: maxRetryCount, recoveringOn: DispatchQueue.global()) { - 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").") + return OnionRequestAPI + .sendOnionRequest(request, to: server, with: serverPublicKey) + .map { _, 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").") + } + + return () + } + .retry(maxRetryCount) + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure: + SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") } } - } - promise.catch2 { error in - SNLog("Couldn't subscribe/unsubscribe for closed group: \(closedGroupPublicKey).") - } - return promise - } - - @objc(performOperation:forClosedGroupWithPublicKey:userPublicKey:) - public static func objc_performOperation(_ operation: ClosedGroupOperation, for closedGroupPublicKey: String, publicKey: String) -> AnyPromise { - return AnyPromise.from(performOperation(operation, for: closedGroupPublicKey, publicKey: publicKey)) + ) + .eraseToAnyPublisher() } // MARK: - Notify @@ -219,34 +261,34 @@ public final class PushNotificationAPI : NSObject { public static func notify( recipient: String, with message: String, - maxRetryCount: UInt? = nil, - queue: DispatchQueue = DispatchQueue.global() - ) -> Promise { + maxRetryCount: Int? = nil + ) -> AnyPublisher { let requestBody: NotifyRequestBody = NotifyRequestBody(data: message, sendTo: recipient) guard let body: Data = try? JSONEncoder().encode(requestBody) else { - return Promise(error: HTTP.Error.invalidJSON) + return Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() } let url = URL(string: "\(server)/notify")! var request: URLRequest = URLRequest(url: url) request.httpMethod = "POST" - request.allHTTPHeaderFields = [ Header.contentType.rawValue: "application/json" ] + request.allHTTPHeaderFields = [ HTTPHeader.contentType: "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 OnionRequestAPI + .sendOnionRequest(request, to: server, with: serverPublicKey) + .map { _, data -> Void in + guard let response: PushServerResponse = try? data?.decoded(as: PushServerResponse.self) else { + return SNLog("Couldn't send push notification.") } - } - - return promise + guard response.code != 0 else { + return SNLog("Couldn't send push notification due to error: \(response.message ?? "nil").") + } + + return () + } + .retry(maxRetryCount ?? PushNotificationAPI.maxRetryCount) + .eraseToAnyPublisher() } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index 801968e12..7f7a798b6 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -1,41 +1,29 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SessionSnodeKit import SessionUtilitiesKit -public final class ClosedGroupPoller { - private var isPolling: Atomic<[String: Bool]> = Atomic([:]) - private var timers: [String: Timer] = [:] +public final class ClosedGroupPoller: Poller { + public static var namespaces: [SnodeAPI.Namespace] = [.legacyClosedGroup] // MARK: - Settings - private static let minPollInterval: Double = 2 - private static let maxPollInterval: Double = 30 - - // MARK: - Error + override var namespaces: [SnodeAPI.Namespace] { ClosedGroupPoller.namespaces } + override var maxNodePollCount: UInt { 0 } - 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." - } - } - } + private static let minPollInterval: Double = 3 + private static let maxPollInterval: Double = 30 // MARK: - Initialization - public static let shared = ClosedGroupPoller() + public static let shared: ClosedGroupPoller = ClosedGroupPoller() // MARK: - Public API - @objc public func start() { + public func start() { // Fetch all closed groups (excluding any don't contain the current user as a // GroupMemeber as the user is no longer a member of those) Storage.shared @@ -50,62 +38,24 @@ public final class ClosedGroupPoller { .fetchAll(db) } .defaulting(to: []) - .forEach { [weak self] groupPublicKey in - self?.startPolling(for: groupPublicKey) + .forEach { [weak self] publicKey in + self?.startIfNeeded(for: publicKey) } } - public func startPolling(for groupPublicKey: String) { - 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 } - setUpPolling(for: groupPublicKey) - } - - public func stopAllPollers() { - let pollers: [String] = Array(isPolling.wrappedValue.keys) - - pollers.forEach { groupPublicKey in - self.stopPolling(for: groupPublicKey) - } - } - - public func stopPolling(for groupPublicKey: String) { - isPolling.mutate { $0[groupPublicKey] = false } - timers[groupPublicKey]?.invalidate() - } - - // MARK: - Private API + // MARK: - Abstract Methods - private func setUpPolling(for groupPublicKey: String) { - Threading.pollerQueue.async { - ClosedGroupPoller.poll(groupPublicKey, poller: self) - .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) - } - } + override func pollerName(for publicKey: String) -> String { + return "closed group with public key: \(publicKey)" } - private func pollRecursively(_ groupPublicKey: String) { - 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 - + override func nextPollDelay(for publicKey: String) -> TimeInterval { + // Get the received date of the last message in the thread. If we don't have + // any messages yet, pick some reasonable fake time interval to use instead let lastMessageDate: Date = Storage.shared .read { db in - try thread - .interactions + try Interaction + .filter(Interaction.Columns.threadId == publicKey) .select(.receivedAtTimestampMs) .order(Interaction.Columns.timestampMs.desc) .asRequest(of: Int64.self) @@ -121,193 +71,19 @@ public final class ClosedGroupPoller { 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.") + let a: TimeInterval = ((ClosedGroupPoller.maxPollInterval - minPollInterval) / limit) + let nextPollInterval: TimeInterval = a * min(timeSinceLastMessage, limit) + minPollInterval + SNLog("Next poll interval for closed group with public key: \(publicKey) is \(nextPollInterval) s.") - timers[groupPublicKey] = Timer.scheduledTimerOnMainThread(withTimeInterval: nextPollInterval, repeats: false) { [weak self] timer in - timer.invalidate() - - 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) - } - } - } + return nextPollInterval } - - public static func poll( - _ groupPublicKey: String, - on queue: DispatchQueue = SessionSnodeKit.Threading.workQueue, - maxRetryCount: UInt = 0, - calledFromBackgroundPoller: 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 - (calledFromBackgroundPoller && isBackgroundPollValid()) || - poller?.isPolling.wrappedValue[groupPublicKey] == true - else { return Promise(error: Error.pollingCanceled) } - - let promises: [Promise<([SnodeReceivedMessage], String?)>] = { - 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 - (calledFromBackgroundPoller && 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 data): return result.appending(contentsOf: data.0) - default: return result - } - } - let allHashes: [String] = messageResults - .reduce([]) { result, next in - switch next { - case .fulfilled(let data): return result.appending(data.1) - default: return result - } - } - .compactMap { $0 } - var messageCount: Int = 0 - var hadValidHashUpdate: Bool = false - - // No need to do anything if there are no messages - guard !allMessages.isEmpty else { - if !calledFromBackgroundPoller { - 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 - - case MessageReceiverError.duplicateMessageNewSnode: - hadValidHashUpdate = true - break - - // In the background ignore 'SQLITE_ABORT' (it generally means - // the BackgroundPoller has timed out - case DatabaseError.SQLITE_ABORT: - guard !calledFromBackgroundPoller else { break } - - SNLog("Failed to the database being suspended (running in background with no background task).") - break - default: SNLog("Failed to deserialize envelope due to error: \(error).") - } - - return nil - } - } - - messageCount = processedMessages.count - - jobToRun = Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: groupPublicKey, - details: MessageReceiveJob.Details( - messages: processedMessages.map { $0.messageInfo }, - calledFromBackgroundPoller: calledFromBackgroundPoller - ) - ) - - // 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: !calledFromBackgroundPoller) - - if messageCount == 0 && !hadValidHashUpdate, !allHashes.isEmpty { - SNLog("Received \(allMessages.count) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey), all duplicates - marking the hashes we polled with as invalid") - - // Update the cached validity of the messages - try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( - db, - potentiallyInvalidHashes: allHashes, - otherKnownValidHashes: allMessages.map { $0.info.hash } - ) - } - } - - if calledFromBackgroundPoller { - // 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 if messageCount > 0 || hadValidHashUpdate { - SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in closed group with public key: \(groupPublicKey) (duplicates: \(allMessages.count - messageCount))") - } - - return when(fulfilled: promises) - } - } - } - - if !calledFromBackgroundPoller { - promise.catch2 { error in - SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).") - } - } - - return promise + override func handlePollError( + _ error: Error, + for publicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Bool { + SNLog("Polling failed for closed group with public key: \(publicKey) due to error: \(error).") + return true } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift new file mode 100644 index 000000000..3936baf4f --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Pollers/CurrentUserPoller.swift @@ -0,0 +1,87 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import Sodium +import SessionSnodeKit +import SessionUtilitiesKit + +public final class CurrentUserPoller: Poller { + public static var namespaces: [SnodeAPI.Namespace] = [ + .default, .configUserProfile, .configContacts, .configConvoInfoVolatile, .configUserGroups + ] + + // MARK: - Settings + + override var namespaces: [SnodeAPI.Namespace] { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled else { return [.default] } + + return CurrentUserPoller.namespaces + } + + /// 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. + override var maxNodePollCount: UInt { 6 } + + private let pollInterval: TimeInterval = 1.5 + private let retryInterval: TimeInterval = 0.25 + private let maxRetryInterval: TimeInterval = 15 + + // MARK: - Convenience Functions + + public func start() { + let publicKey: String = getUserHexEncodedPublicKey() + + guard isPolling.wrappedValue[publicKey] != true else { return } + + SNLog("Started polling.") + super.startIfNeeded(for: publicKey) + } + + public func stop() { + SNLog("Stopped polling.") + super.stopAllPollers() + } + + // MARK: - Abstract Methods + + override func pollerName(for publicKey: String) -> String { + return "Main Poller" + } + + override func nextPollDelay(for publicKey: String) -> TimeInterval { + let failureCount: TimeInterval = TimeInterval(failureCount.wrappedValue[publicKey] ?? 0) + + // If there have been no failures then just use the 'minPollInterval' + guard failureCount > 0 else { return pollInterval } + + // Otherwise use a simple back-off with the 'retryInterval' + let nextDelay: TimeInterval = (retryInterval * (failureCount * 1.2)) + + return min(maxRetryInterval, nextDelay) + } + + override func handlePollError( + _ error: Error, + for publicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> Bool { + if UserDefaults.sharedLokiProject?[.isMainAppActive] != true { + // Do nothing when an error gets throws right after returning from the background (happens frequently) + } + else if let targetSnode: Snode = targetSnode.wrappedValue { + SNLog("Main Poller polling \(targetSnode) failed; dropping it and switching to next snode.") + self.targetSnode.mutate { $0 = nil } + SnodeAPI.dropSnodeFromSwarmIfNeeded(targetSnode, publicKey: publicKey) + } + else { + SNLog("Polling failed due to having no target service node.") + } + + return true + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift index 48b8c0dff..48cb16f64 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/OpenGroupPoller.swift @@ -1,25 +1,33 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB -import PromiseKit import SessionSnodeKit import SessionUtilitiesKit extension OpenGroupAPI { public final class Poller { - typealias PollResponse = [OpenGroupAPI.Endpoint: (info: OnionRequestResponseInfoType, data: Codable?)] + typealias PollResponse = (info: ResponseInfoType, data: [OpenGroupAPI.Endpoint: Decodable]) private let server: String private var timer: Timer? = nil - private var hasStarted = false - private var isPolling = false + private var hasStarted: Bool = false + private var isPolling: Bool = 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) + private static let maxPollInterval: TimeInterval = (60 * 60) + internal static let maxInactivityPeriod: TimeInterval = (14 * 24 * 60 * 60) + + /// If there are hidden rooms that we poll and they fail too many times we want to prune them (as it likely means they no longer + /// exist, and since they are already hidden it's unlikely that the user will notice that we stopped polling for them) + internal static let maxHiddenRoomFailureCount: Int64 = 10 + + /// When doing a background poll we want to only fetch from rooms which are unlikely to timeout, in order to do this we exclude + /// any rooms which have failed more than this threashold + public static let maxRoomFailureCountForBackgroundPoll: Int64 = 15 // MARK: - Lifecycle @@ -41,155 +49,252 @@ extension OpenGroupAPI { // MARK: - Polling - private func pollRecursively(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) { + private func pollRecursively( + using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies( + subscribeQueue: Threading.pollerQueue, + receiveQueue: OpenGroupAPI.workQueue + ) + ) { 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) + let server: String = self.server + let lastPollStart: TimeInterval = Date().timeIntervalSince1970 - poll(using: dependencies).retainUntilComplete() - timer = Timer.scheduledTimerOnMainThread(withTimeInterval: nextPollInterval, repeats: false) { [weak self] timer in - timer.invalidate() - - Threading.pollerQueue.async { - self?.pollRecursively(using: dependencies) - } - } + poll(using: dependencies) + .subscribe(on: dependencies.subscribeQueue) + .receive(on: dependencies.receiveQueue) + .sinkUntilComplete( + receiveCompletion: { [weak self] _ in + let minPollFailureCount: Int64 = dependencies.storage + .read { db in + try OpenGroup + .filter(OpenGroup.Columns.server == server) + .select(min(OpenGroup.Columns.pollFailureCount)) + .asRequest(of: Int64.self) + .fetchOne(db) + } + .defaulting(to: 0) + + // Calculate the remaining poll delay + let currentTime: TimeInterval = Date().timeIntervalSince1970 + let nextPollInterval: TimeInterval = Poller.getInterval( + for: TimeInterval(minPollFailureCount), + minInterval: Poller.minPollInterval, + maxInterval: Poller.maxPollInterval + ) + let remainingInterval: TimeInterval = max(0, nextPollInterval - (currentTime - lastPollStart)) + + // Schedule the next poll + guard remainingInterval > 0 else { + return dependencies.subscribeQueue.async { + self?.pollRecursively(using: dependencies) + } + } + + dependencies.subscribeQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default) { + self?.pollRecursively(using: dependencies) + } + } + ) } - @discardableResult - public func poll(using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies()) -> Promise { - return poll(calledFromBackgroundPoller: false, isPostCapabilitiesRetry: false, using: dependencies) + public func poll( + using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() + ) -> AnyPublisher { + return poll( + calledFromBackgroundPoller: false, + isPostCapabilitiesRetry: false, + using: dependencies + ) } - @discardableResult public func poll( calledFromBackgroundPoller: Bool, isBackgroundPollerValid: @escaping (() -> Bool) = { true }, isPostCapabilitiesRetry: Bool, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() - ) -> Promise { - guard !self.isPolling else { return Promise.value(()) } + ) -> AnyPublisher { + guard !self.isPolling else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } self.isPolling = true let server: String = self.server - let (promise, seal) = Promise.pending() - promise.retainUntilComplete() + let hasPerformedInitialPoll: Bool = (dependencies.cache.hasPerformedInitialPoll[server] == true) + let timeSinceLastPoll: TimeInterval = ( + dependencies.cache.timeSinceLastPoll[server] ?? + dependencies.mutableCache.mutate { $0.getTimeSinceLastOpen(using: dependencies) } + ) - let pollingLogic: () -> Void = { - 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( + return dependencies.storage + .readPublisher { db -> (Int64, PreparedSendData) in + let failureCount: Int64 = (try? OpenGroup + .filter(OpenGroup.Columns.server == server) + .select(max(OpenGroup.Columns.pollFailureCount)) + .asRequest(of: Int64.self) + .fetchOne(db)) + .defaulting(to: 0) + + return ( + failureCount, + try OpenGroupAPI + .preparedPoll( db, server: server, - hasPerformedInitialPoll: dependencies.cache.hasPerformedInitialPoll[server] == true, - timeSinceLastPoll: ( - dependencies.cache.timeSinceLastPoll[server] ?? - dependencies.cache.getTimeSinceLastOpen(using: dependencies) - ), + hasPerformedInitialPoll: hasPerformedInitialPoll, + timeSinceLastPoll: timeSinceLastPoll, using: dependencies ) - .map(on: OpenGroupAPI.workQueue) { (failureCount, $0) } - } - .done(on: OpenGroupAPI.workQueue) { [weak self] failureCount, response in + ) + } + .flatMap { failureCount, sendData in + OpenGroupAPI.send(data: sendData, using: dependencies) + .map { info, response in (failureCount, info, response) } + } + .handleEvents( + receiveOutput: { [weak self] failureCount, info, response in guard !calledFromBackgroundPoller || 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, + info: info, + response: response, failureCount: failureCount, 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 !calledFromBackgroundPoller || 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( + ) + .map { _ in () } + .catch { [weak self] error -> AnyPublisher in + guard + let strongSelf = self, + (!calledFromBackgroundPoller || isBackgroundPollerValid()) + else { + // If this was a background poll and the background poll is no longer valid + // then just stop + self?.isPolling = false + + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + // If we are retrying then the error is being handled so no need to continue (this + // method will always resolve) + return strongSelf + .updateCapabilitiesAndRetryIfNeeded( server: server, calledFromBackgroundPoller: calledFromBackgroundPoller, isBackgroundPollerValid: isBackgroundPollerValid, isPostCapabilitiesRetry: isPostCapabilitiesRetry, - error: error + error: error, + using: dependencies ) - .done(on: OpenGroupAPI.workQueue) { [weak self] didHandleError in - if !didHandleError && isBackgroundPollerValid() { - // Increase the failure count - let pollFailureCount: Int64 = Storage.shared - .read { db in + .handleEvents( + receiveOutput: { [weak self] didHandleError in + if !didHandleError && isBackgroundPollerValid() { + // 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) + var prunedIds: [String] = [] + + dependencies.storage.writeAsync { db in + struct Info: Decodable, FetchableRecord { + let id: String + let shouldBeVisible: Bool + } + + let rooms: [String] = try OpenGroup + .filter( + OpenGroup.Columns.server == server && + OpenGroup.Columns.isActive == true + ) + .select(.roomToken) + .asRequest(of: String.self) + .fetchAll(db) + let roomsAreVisible: [Info] = try SessionThread + .select(.id, .shouldBeVisible) + .filter( + ids: rooms.map { + OpenGroup.idFor(roomToken: $0, server: server) + } + ) + .asRequest(of: Info.self) + .fetchAll(db) + + // Increase the failure count try OpenGroup .filter(OpenGroup.Columns.server == server) - .select(max(OpenGroup.Columns.pollFailureCount)) - .asRequest(of: Int64.self) - .fetchOne(db) + .updateAll( + db, + OpenGroup.Columns.pollFailureCount + .set(to: (pollFailureCount + 1)) + ) + + /// If the polling has failed 10+ times then try to prune any invalid rooms that + /// aren't visible (they would have been added via config messages and will + /// likely always fail but the user has no way to delete them) + guard pollFailureCount > Poller.maxHiddenRoomFailureCount else { return } + + prunedIds = roomsAreVisible + .filter { !$0.shouldBeVisible } + .map { $0.id } + + prunedIds.forEach { id in + OpenGroupManager.shared.delete( + db, + openGroupId: id, + /// **Note:** We pass `calledFromConfigHandling` as `true` + /// here because we want to avoid syncing this deletion as the room might + /// not be in an invalid state on other devices - one of the other devices + /// will eventually trigger a new config update which will re-add this room + /// and hopefully at that time it'll work again + calledFromConfigHandling: true, + using: dependencies + ) + } + } + + SNLog("Open group polling to \(server) failed due to error: \(error). Setting failure count to \(pollFailureCount).") + + // Add a note to the logs that this happened + if !prunedIds.isEmpty { + let rooms: String = prunedIds + .compactMap { $0.components(separatedBy: server).last } + .joined(separator: ", ") + SNLog("Hidden open group failure count surpassed \(Poller.maxHiddenRoomFailureCount), removed hidden rooms \(rooms).") } - .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 } - - self?.isPolling = false - seal.fulfill(()) // The promise is just used to keep track of when we're done - } - .retainUntilComplete() - } - } - - // If this was run via the background poller then don't run on the pollerQueue - if calledFromBackgroundPoller { - pollingLogic() - } - else { - Threading.pollerQueue.async { pollingLogic() } - } - - return promise + ) + .map { _ in () } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } private func updateCapabilitiesAndRetryIfNeeded( @@ -199,7 +304,7 @@ extension OpenGroupAPI { isPostCapabilitiesRetry: Bool, error: Error, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() - ) -> Promise { + ) -> AnyPublisher { /// 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 /// @@ -212,21 +317,28 @@ extension OpenGroupAPI { statusCode == 400, let dataString: String = String(data: data, encoding: .utf8), dataString.contains("Invalid authentication: this server requires the use of blinded ids") - else { return Promise.value(false) } + else { + return Just(false) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } - let (promise, seal) = Promise.pending() - - dependencies.storage - .read { db in - OpenGroupAPI.capabilities( + return dependencies.storage + .readPublisher { db in + try OpenGroupAPI.preparedCapabilities( db, server: server, forceBlinded: true, using: dependencies ) } - .then(on: OpenGroupAPI.workQueue) { [weak self] _, responseBody -> Promise in - guard let strongSelf = self, isBackgroundPollerValid() else { return Promise.value(()) } + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .flatMap { [weak self] _, responseBody -> AnyPublisher in + guard let strongSelf = self, isBackgroundPollerValid() else { + return Just(()) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } // Handle the updated capabilities and re-trigger the poll strongSelf.isPolling = false @@ -247,28 +359,31 @@ extension OpenGroupAPI { isPostCapabilitiesRetry: true, using: dependencies ) - .ensure { seal.fulfill(true) } + .map { _ in () } + .eraseToAnyPublisher() } - .catch(on: OpenGroupAPI.workQueue) { error in + .map { _ in true } + .catch { error -> AnyPublisher in SNLog("Open group updating capabilities failed due to error: \(error).") - seal.fulfill(true) + return Just(true) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - .retainUntilComplete() - - return promise + .eraseToAnyPublisher() } private func handlePollResponse( - _ response: PollResponse, + info: ResponseInfoType, + response: BatchResponse, failureCount: Int64, using dependencies: OpenGroupManager.OGMDependencies = OpenGroupManager.OGMDependencies() ) { let server: String = self.server - let validResponses: PollResponse = response - .filter { endpoint, endpointResponse in + let validResponses: [OpenGroupAPI.Endpoint: Decodable] = response.data + .filter { endpoint, data in switch endpoint { case .capabilities: - guard (endpointResponse.data as? BatchSubResponse)?.body != nil else { + guard (data as? HTTP.BatchSubResponse)?.body != nil else { SNLog("Open group polling failed due to invalid capability data.") return false } @@ -276,8 +391,8 @@ extension OpenGroupAPI { return true case .roomPollInfo(let roomToken, _): - guard (endpointResponse.data as? BatchSubResponse)?.body != nil else { - switch (endpointResponse.data as? BatchSubResponse)?.code { + guard (data as? HTTP.BatchSubResponse)?.body != nil else { + switch (data as? HTTP.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.") } @@ -288,10 +403,10 @@ extension OpenGroupAPI { case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): guard - let responseData: BatchSubResponse<[Failable]> = endpointResponse.data as? BatchSubResponse<[Failable]>, + let responseData: HTTP.BatchSubResponse<[Failable]> = data as? HTTP.BatchSubResponse<[Failable]>, let responseBody: [Failable] = responseData.body else { - switch (endpointResponse.data as? BatchSubResponse<[Failable]>)?.code { + switch (data as? HTTP.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.") } @@ -310,7 +425,7 @@ extension OpenGroupAPI { case .inbox, .inboxSince, .outbox, .outboxSince: guard - let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, + let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = data as? HTTP.BatchSubResponse<[DirectMessage]?>, !responseData.failedToParseBody else { SNLog("Open group polling failed due to invalid inbox/outbox data.") @@ -363,12 +478,12 @@ extension OpenGroupAPI { return (capabilities, groups) } - let changedResponses: PollResponse = validResponses - .filter { endpoint, endpointResponse in + let changedResponses: [OpenGroupAPI.Endpoint: Decodable] = validResponses + .filter { endpoint, data in switch endpoint { case .capabilities: guard - let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, + let responseData: HTTP.BatchSubResponse = data as? HTTP.BatchSubResponse, let responseBody: Capabilities = responseData.body else { return false } @@ -376,7 +491,7 @@ extension OpenGroupAPI { case .roomPollInfo(let roomToken, _): guard - let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, + let responseData: HTTP.BatchSubResponse = data as? HTTP.BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { return false } guard let existingOpenGroup: OpenGroup = currentInfo?.groups.first(where: { $0.roomToken == roomToken }) else { @@ -409,11 +524,11 @@ extension OpenGroupAPI { .updateAll(db, OpenGroup.Columns.pollFailureCount.set(to: 0)) } - try changedResponses.forEach { endpoint, endpointResponse in + try changedResponses.forEach { endpoint, data in switch endpoint { case .capabilities: guard - let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, + let responseData: HTTP.BatchSubResponse = data as? HTTP.BatchSubResponse, let responseBody: Capabilities = responseData.body else { return } @@ -425,7 +540,7 @@ extension OpenGroupAPI { case .roomPollInfo(let roomToken, _): guard - let responseData: BatchSubResponse = endpointResponse.data as? BatchSubResponse, + let responseData: HTTP.BatchSubResponse = data as? HTTP.BatchSubResponse, let responseBody: RoomPollInfo = responseData.body else { return } @@ -440,7 +555,7 @@ extension OpenGroupAPI { case .roomMessagesRecent(let roomToken), .roomMessagesBefore(let roomToken, _), .roomMessagesSince(let roomToken, _): guard - let responseData: BatchSubResponse<[Failable]> = endpointResponse.data as? BatchSubResponse<[Failable]>, + let responseData: HTTP.BatchSubResponse<[Failable]> = data as? HTTP.BatchSubResponse<[Failable]>, let responseBody: [Failable] = responseData.body else { return } @@ -454,7 +569,7 @@ extension OpenGroupAPI { case .inbox, .inboxSince, .outbox, .outboxSince: guard - let responseData: BatchSubResponse<[DirectMessage]?> = endpointResponse.data as? BatchSubResponse<[DirectMessage]?>, + let responseData: HTTP.BatchSubResponse<[DirectMessage]?> = data as? HTTP.BatchSubResponse<[DirectMessage]?>, !responseData.failedToParseBody else { return } @@ -480,12 +595,12 @@ extension OpenGroupAPI { } } } - } - - // MARK: - Convenience + + // MARK: - Convenience - fileprivate static func getInterval(for failureCount: TimeInterval, minInterval: TimeInterval, maxInterval: TimeInterval) -> TimeInterval { - // Arbitrary backoff factor... - return min(maxInterval, minInterval + pow(2, failureCount)) + 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/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index 677acbaf5..bd984c6ea 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -1,221 +1,469 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine 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 - +public class Poller { + private var cancellables: Atomic<[String: AnyCancellable]> = Atomic([:]) + internal var isPolling: Atomic<[String: Bool]> = Atomic([:]) + internal var pollCount: Atomic<[String: Int]> = Atomic([:]) + internal var failureCount: Atomic<[String: Int]> = Atomic([:]) + + internal var targetSnode: Atomic = Atomic(nil) + private var usedSnodes: Atomic> = Atomic([]) + // MARK: - Settings - private static let pollInterval: TimeInterval = 1.5 - private static let retryInterval: TimeInterval = 0.25 - private static let maxRetryInterval: TimeInterval = 15 + /// The namespaces which this poller queries + internal var namespaces: [SnodeAPI.Namespace] { + preconditionFailure("abstract class - override in subclass") + } - /// 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 { - case pollLimitReached - - var localizedDescription: String { - switch self { - case .pollLimitReached: return "Poll limit reached for current snode." - } - } + /// The number of times the poller can poll a single snode before swapping to a new snode + internal var maxNodePollCount: UInt { + preconditionFailure("abstract class - override in subclass") } // MARK: - Public API public init() {} - public func startIfNeeded() { - guard !isPolling.wrappedValue else { return } + public func stopAllPollers() { + let pollers: [String] = Array(isPolling.wrappedValue.keys) - SNLog("Started polling.") - isPolling.mutate { $0 = true } - setUpPolling() + pollers.forEach { groupPublicKey in + self.stopPolling(for: groupPublicKey) + } } - - public func stop() { - SNLog("Stopped polling.") - isPolling.mutate { $0 = false } - usedSnodes.removeAll() + + public func stopPolling(for publicKey: String) { + isPolling.mutate { $0[publicKey] = false } + cancellables.mutate { $0[publicKey]?.cancel() } + } + + // MARK: - Abstract Methods + + /// The name for this poller to appear in the logs + internal func pollerName(for publicKey: String) -> String { + preconditionFailure("abstract class - override in subclass") + } + + /// Calculate the delay which should occur before the next poll + internal func nextPollDelay(for publicKey: String) -> TimeInterval { + preconditionFailure("abstract class - override in subclass") + } + + /// Perform and logic which should occur when the poll errors, will stop polling if `false` is returned + internal func handlePollError(_ error: Error, for publicKey: String, using dependencies: SMKDependencies) -> Bool { + preconditionFailure("abstract class - override in subclass") } // 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() - } - } + internal func startIfNeeded(for publicKey: String) { + // Run on the 'pollerQueue' to ensure any 'Atomic' access doesn't block the main thread + // on startup + Threading.pollerQueue.async { [weak self] in + guard self?.isPolling.wrappedValue[publicKey] != 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 + self?.isPolling.mutate { $0[publicKey] = true } + self?.pollRecursively(for: publicKey) } } - - private func pollNextSnode(seal: Resolver) { - let userPublicKey = getUserHexEncodedPublicKey() - let swarm = SnodeAPI.swarmCache.wrappedValue[userPublicKey] ?? [] - let unusedSnodes = swarm.subtracting(usedSnodes) - - guard !unusedSnodes.isEmpty else { - seal.fulfill(()) - return + + internal func getSnodeForPolling( + for publicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) -> AnyPublisher { + // If we don't want to poll a snode multiple times then just grab a random one from the swarm + guard maxNodePollCount > 0 else { + return SnodeAPI.getSwarm(for: publicKey, using: dependencies) + .tryMap { swarm -> Snode in + try swarm.randomElement() ?? { throw OnionRequestAPIError.insufficientSnodes }() + } + .eraseToAnyPublisher() } - // randomElement() uses the system's default random generator, which is cryptographically secure - let nextSnode = unusedSnodes.randomElement()! - usedSnodes.insert(nextSnode) + // If we already have a target snode then use that + if let targetSnode: Snode = self.targetSnode.wrappedValue { + return Just(targetSnode) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } - poll(nextSnode, seal: seal) - .done2 { - seal.fulfill(()) + // Select the next unused snode from the swarm (if we've used them all then clear the used list and + // start cycling through them again) + return SnodeAPI.getSwarm(for: publicKey, using: dependencies) + .tryMap { [usedSnodes = self.usedSnodes, targetSnode = self.targetSnode] swarm -> Snode in + let unusedSnodes: Set = swarm.subtracting(usedSnodes.wrappedValue) + + // If we've used all of the SNodes then clear out the used list + if unusedSnodes.isEmpty { + usedSnodes.mutate { $0.removeAll() } + } + + // Select the next SNode + let nextSnode: Snode = try swarm.randomElement() ?? { throw OnionRequestAPIError.insufficientSnodes }() + targetSnode.mutate { $0 = nextSnode } + usedSnodes.mutate { $0.insert(nextSnode) } + + return nextSnode } - .catch2 { [weak self] error in - if let error = error as? Error, error == .pollLimitReached { - self?.pollCount = 0 - } - else if UserDefaults.sharedLokiProject?[.isMainAppActive] != true { - // Do nothing when an error gets throws right after returning from the background (happens frequently) + .eraseToAnyPublisher() + } + + internal func incrementPollCount(publicKey: String) { + guard maxNodePollCount > 0 else { return } + + let pollCount: Int = (self.pollCount.wrappedValue[publicKey] ?? 0) + self.pollCount.mutate { $0[publicKey] = (pollCount + 1) } + + // Check if we've polled the serice node too many times + guard pollCount > maxNodePollCount else { return } + + // If we have polled this service node more than the maximum allowed then clear out + // the 'targetServiceNode' value + self.targetSnode.mutate { $0 = nil } + } + + private func pollRecursively( + for publicKey: String, + using dependencies: SMKDependencies = SMKDependencies() + ) { + guard isPolling.wrappedValue[publicKey] == true else { return } + + let namespaces: [SnodeAPI.Namespace] = self.namespaces + let lastPollStart: TimeInterval = Date().timeIntervalSince1970 + let lastPollInterval: TimeInterval = nextPollDelay(for: publicKey) + let getSnodePublisher: AnyPublisher = getSnodeForPolling(for: publicKey) + + // Store the publisher intp the cancellables dictionary + cancellables.mutate { [weak self] cancellables in + cancellables[publicKey] = getSnodePublisher + .flatMap { snode -> AnyPublisher<[Message], Error> in + Poller.poll( + namespaces: namespaces, + from: snode, + for: publicKey, + poller: self, + using: dependencies + ) } + .subscribe(on: dependencies.subscribeQueue) + .receive(on: dependencies.receiveQueue) + .sink( + receiveCompletion: { result in + switch result { + case .failure(let error): + // Determine if the error should stop us from polling anymore + guard self?.handlePollError(error, for: publicKey, using: dependencies) == true else { + return + } + + case .finished: break + } + + // Increment the poll count + self?.incrementPollCount(publicKey: publicKey) + + // Calculate the remaining poll delay + let currentTime: TimeInterval = Date().timeIntervalSince1970 + let nextPollInterval: TimeInterval = ( + self?.nextPollDelay(for: publicKey) ?? + lastPollInterval + ) + let remainingInterval: TimeInterval = max(0, nextPollInterval - (currentTime - lastPollStart)) + + // Schedule the next poll + guard remainingInterval > 0 else { + return dependencies.subscribeQueue.async { + self?.pollRecursively(for: publicKey, using: dependencies) + } + } + + dependencies.subscribeQueue.asyncAfter(deadline: .now() + .milliseconds(Int(remainingInterval * 1000)), qos: .default) { + self?.pollRecursively(for: publicKey, using: dependencies) + } + }, + receiveValue: { _ in } + ) + } + } + + /// Polls the specified namespaces and processes any messages, returning an array of messages that were + /// successfully processed + /// + /// **Note:** The returned messages will have already been processed by the `Poller`, they are only returned + /// for cases where we need explicit/custom behaviours to occur (eg. Onboarding) + public static func poll( + namespaces: [SnodeAPI.Namespace], + from snode: Snode, + for publicKey: String, + calledFromBackgroundPoller: Bool = false, + isBackgroundPollValid: @escaping (() -> Bool) = { true }, + poller: Poller? = nil, + using dependencies: SMKDependencies = SMKDependencies( + subscribeQueue: Threading.pollerQueue, + receiveQueue: Threading.pollerQueue + ) + ) -> AnyPublisher<[Message], Error> { + // If the polling has been cancelled then don't continue + guard + (calledFromBackgroundPoller && isBackgroundPollValid()) || + poller?.isPolling.wrappedValue[publicKey] == true + else { + return Just([]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + let pollerName: String = ( + poller?.pollerName(for: publicKey) ?? + "poller with public key \(publicKey)" + ) + let configHashes: [String] = SessionUtil.configHashes(for: publicKey) + + // Fetch the messages + return SnodeAPI + .poll( + namespaces: namespaces, + refreshingConfigHashes: configHashes, + from: snode, + associatedWith: publicKey, + using: dependencies + ) + .flatMap { namespacedResults -> AnyPublisher<[Message], Error> in + guard + (calledFromBackgroundPoller && isBackgroundPollValid()) || + poller?.isPolling.wrappedValue[publicKey] == true else { - SNLog("Polling \(nextSnode) failed; dropping it and switching to next snode.") - SnodeAPI.dropSnodeFromSwarmIfNeeded(nextSnode, publicKey: userPublicKey) + return Just([]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - Threading.pollerQueue.async { - self?.pollNextSnode(seal: seal) - } - } - } - - private func poll(_ snode: Snode, seal longTermSeal: Resolver) -> Promise { - 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, lastHash -> Promise in - guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } } + let allMessages: [SnodeReceivedMessage] = namespacedResults + .compactMap { _, result -> [SnodeReceivedMessage]? in result.data?.messages } + .flatMap { $0 } - if !messages.isEmpty { - var messageCount: Int = 0 - var hadValidHashUpdate: Bool = false + // No need to do anything if there are no messages + guard !allMessages.isEmpty else { + if !calledFromBackgroundPoller { SNLog("Received no new messages in \(pollerName)") } - Storage.shared.write { db in - messages - .compactMap { message -> ProcessedMessage? in + return Just([]) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + // Otherwise process the messages and add them to the queue for handling + let lastHashes: [String] = namespacedResults + .compactMap { $0.value.data?.lastHash } + let otherKnownHashes: [String] = namespacedResults + .filter { $0.key.shouldDedupeMessages } + .compactMap { $0.value.data?.messages.map { $0.info.hash } } + .reduce([], +) + var messageCount: Int = 0 + var processedMessages: [Message] = [] + var hadValidHashUpdate: Bool = false + var configMessageJobsToRun: [Job] = [] + var standardMessageJobsToRun: [Job] = [] + var pollerLogOutput: String = "\(pollerName) failed to process any messages" + + Storage.shared.write { db in + let allProcessedMessages: [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 + + case MessageReceiverError.duplicateMessageNewSnode: + hadValidHashUpdate = true + break + + case DatabaseError.SQLITE_ABORT: + // In the background ignore 'SQLITE_ABORT' (it generally means + // the BackgroundPoller has timed out + if !calledFromBackgroundPoller { + 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 + } + } + + // Add a job to process the config messages first + let configJobIds: [Int64] = allProcessedMessages + .filter { $0.messageInfo.variant == .sharedConfigMessage } + .grouped { threadId, _, _, _ in threadId } + .compactMap { threadId, threadMessages in + messageCount += threadMessages.count + processedMessages += threadMessages.map { $0.messageInfo.message } + + let jobToRun: Job? = Job( + variant: .configMessageReceive, + behaviour: .runOnce, + threadId: threadId, + details: ConfigMessageReceiveJob.Details( + messages: threadMessages.map { $0.messageInfo }, + calledFromBackgroundPoller: calledFromBackgroundPoller + ) + ) + configMessageJobsToRun = configMessageJobsToRun.appending(jobToRun) + + // If we are force-polling then add to the JobRunner so they are + // persistent and will retry on the next app run if they fail but + // don't let them auto-start + let updatedJob: Job? = dependencies.jobRunner + .add( + db, + job: jobToRun, + canStartJob: !calledFromBackgroundPoller, + dependencies: dependencies + ) + + return updatedJob?.id + } + + // Add jobs for processing non-config messages which are dependant on the config message + // processing jobs + allProcessedMessages + .filter { $0.messageInfo.variant != .sharedConfigMessage } + .grouped { threadId, _, _, _ in threadId } + .forEach { threadId, threadMessages in + messageCount += threadMessages.count + processedMessages += threadMessages.map { $0.messageInfo.message } + + let jobToRun: Job? = Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: threadId, + details: MessageReceiveJob.Details( + messages: threadMessages.map { $0.messageInfo }, + calledFromBackgroundPoller: calledFromBackgroundPoller + ) + ) + standardMessageJobsToRun = standardMessageJobsToRun.appending(jobToRun) + + // If we are force-polling then add to the JobRunner so they are + // persistent and will retry on the next app run if they fail but + // don't let them auto-start + let updatedJob: Job? = dependencies.jobRunner + .add( + db, + job: jobToRun, + canStartJob: !calledFromBackgroundPoller, + dependencies: dependencies + ) + + // Create the dependency between the jobs + if let updatedJobId: Int64 = updatedJob?.id { do { - return try Message.processRawReceivedMessage(db, rawMessage: message) + try configJobIds.forEach { configJobId in + try JobDependencies( + jobId: updatedJobId, + dependantId: configJobId + ) + .insert(db) + } } 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 MessageReceiverError.duplicateMessageNewSnode: - hadValidHashUpdate = true - 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 + SNLog("Failed to add dependency between config processing and non-config processing messageReceive jobs.") } } - .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 }, - calledFromBackgroundPoller: false - ) - ) - ) - } - - if messageCount == 0 && !hadValidHashUpdate, let lastHash: String = lastHash { - SNLog("Received \(messages.count) new message\(messages.count == 1 ? "" : "s"), all duplicates - marking the hash we polled with as invalid") - - // Update the cached validity of the messages - try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( - db, - potentiallyInvalidHashes: [lastHash], - otherKnownValidHashes: messages.map { $0.info.hash } - ) } - else { - SNLog("Received \(messageCount) new message\(messageCount == 1 ? "" : "s") (duplicates: \(messages.count - messageCount))") - } - } - } - 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.wrappedValue else { - return Promise { $0.fulfill(()) } - } - return strongSelf.poll(snode, seal: longTermSeal) + // Set the output for logging + pollerLogOutput = "Received \(messageCount) new message\(messageCount == 1 ? "" : "s") in \(pollerName) (duplicates: \(allMessages.count - messageCount))" + + // Clean up message hashes and add some logs about the poll results + if allMessages.isEmpty && !hadValidHashUpdate { + pollerLogOutput = "Received \(allMessages.count) new message\(allMessages.count == 1 ? "" : "s") in \(pollerName), all duplicates - marking the hash we polled with as invalid" + + // Update the cached validity of the messages + try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: lastHashes, + otherKnownValidHashes: otherKnownHashes + ) + } } + + // Only output logs if it isn't the background poller + if !calledFromBackgroundPoller { + SNLog(pollerLogOutput) + } + + // If we aren't runing in a background poller then just finish immediately + guard calledFromBackgroundPoller else { + return Just(processedMessages) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + // We want to try to handle the receive jobs immediately in the background + return Publishers + .MergeMany( + configMessageJobsToRun.map { job -> AnyPublisher in + Deferred { + Future { resolver in + // Note: In the background we just want jobs to fail silently + ConfigMessageReceiveJob.run( + job, + queue: dependencies.receiveQueue, + success: { _, _, _ in resolver(Result.success(())) }, + failure: { _, _, _, _ in resolver(Result.success(())) }, + deferred: { _, _ in resolver(Result.success(())) } + ) + } + } + .eraseToAnyPublisher() + } + ) + .collect() + .flatMap { _ in + Publishers + .MergeMany( + standardMessageJobsToRun.map { job -> AnyPublisher in + Deferred { + Future { resolver in + // Note: In the background we just want jobs to fail silently + MessageReceiveJob.run( + job, + queue: dependencies.receiveQueue, + success: { _, _, _ in resolver(Result.success(())) }, + failure: { _, _, _, _ in resolver(Result.success(())) }, + deferred: { _, _ in resolver(Result.success(())) } + ) + } + } + .eraseToAnyPublisher() + } + ) + .collect() + } + .map { _ in processedMessages } + .eraseToAnyPublisher() } + .eraseToAnyPublisher() } } diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index 562621a36..ae99e4453 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -13,7 +13,8 @@ public struct QuotedReplyModel { public let sourceFileName: String? public let thumbnailDownloadFailed: Bool public let currentUserPublicKey: String? - public let currentUserBlindedPublicKey: String? + public let currentUserBlinded15PublicKey: String? + public let currentUserBlinded25PublicKey: String? // MARK: - Initialization @@ -27,7 +28,8 @@ public struct QuotedReplyModel { sourceFileName: String?, thumbnailDownloadFailed: Bool, currentUserPublicKey: String?, - currentUserBlindedPublicKey: String? + currentUserBlinded15PublicKey: String?, + currentUserBlinded25PublicKey: String? ) { self.attachment = attachment self.threadId = threadId @@ -38,7 +40,8 @@ public struct QuotedReplyModel { self.sourceFileName = sourceFileName self.thumbnailDownloadFailed = thumbnailDownloadFailed self.currentUserPublicKey = currentUserPublicKey - self.currentUserBlindedPublicKey = currentUserBlindedPublicKey + self.currentUserBlinded15PublicKey = currentUserBlinded15PublicKey + self.currentUserBlinded25PublicKey = currentUserBlinded25PublicKey } public static func quotedReplyForSending( @@ -50,7 +53,8 @@ public struct QuotedReplyModel { attachments: [Attachment]?, linkPreviewAttachment: Attachment?, currentUserPublicKey: String?, - currentUserBlindedPublicKey: String? + currentUserBlinded15PublicKey: String?, + currentUserBlinded25PublicKey: String? ) -> QuotedReplyModel? { guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } guard (body != nil && body?.isEmpty == false) || attachments?.isEmpty == false else { return nil } @@ -67,20 +71,8 @@ public struct QuotedReplyModel { sourceFileName: targetAttachment?.sourceFilename, thumbnailDownloadFailed: false, currentUserPublicKey: currentUserPublicKey, - currentUserBlindedPublicKey: currentUserBlindedPublicKey + currentUserBlinded15PublicKey: currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: currentUserBlinded25PublicKey ) } } - -// 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/Typing Indicators/TypingIndicators.swift b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift index afebe2c10..b8c4d3b49 100644 --- a/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift +++ b/SessionMessagingKit/Sending & Receiving/Typing Indicators/TypingIndicators.swift @@ -15,6 +15,7 @@ public class TypingIndicators { private class Indicator { fileprivate let threadId: String + fileprivate let threadVariant: SessionThread.Variant fileprivate let direction: Direction fileprivate let timestampMs: Int64 @@ -24,6 +25,7 @@ public class TypingIndicators { init?( threadId: String, threadVariant: SessionThread.Variant, + threadIsBlocked: Bool, threadIsMessageRequest: Bool, direction: Direction, timestampMs: Int64? @@ -33,14 +35,21 @@ public class TypingIndicators { // 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 + Storage.shared[.typingIndicatorsEnabled] && + !threadIsBlocked && + !threadIsMessageRequest + else { return nil } // Don't send typing indicators in group threads - guard threadVariant != .closedGroup && threadVariant != .openGroup else { return nil } + guard + threadVariant != .legacyGroup && + threadVariant != .group && + threadVariant != .community + else { return nil } self.threadId = threadId + self.threadVariant = threadVariant self.direction = direction self.timestampMs = (timestampMs ?? SnodeAPI.currentOffsetTimestampMs()) } @@ -71,15 +80,12 @@ public class TypingIndicators { 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 + threadId: threadId, + threadVariant: threadVariant ) case .incoming: @@ -107,15 +113,12 @@ public class TypingIndicators { private func scheduleRefreshCallback(_ db: Database, shouldSend: Bool = true) { if shouldSend { - guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: self.threadId) else { - return - } - try? MessageSender.send( db, message: TypingIndicator(kind: .started), interactionId: nil, - in: thread + threadId: threadId, + threadVariant: threadVariant ) } @@ -143,6 +146,7 @@ public class TypingIndicators { public static func didStartTypingNeedsToStart( threadId: String, threadVariant: SessionThread.Variant, + threadIsBlocked: Bool, threadIsMessageRequest: Bool, direction: Direction, timestampMs: Int64? @@ -159,6 +163,7 @@ public class TypingIndicators { let newIndicator: Indicator? = Indicator( threadId: threadId, threadVariant: threadVariant, + threadIsBlocked: threadIsBlocked, threadIsMessageRequest: threadIsMessageRequest, direction: direction, timestampMs: timestampMs @@ -179,6 +184,7 @@ public class TypingIndicators { let newIndicator: Indicator? = Indicator( threadId: threadId, threadVariant: threadVariant, + threadIsBlocked: threadIsBlocked, threadIsMessageRequest: threadIsMessageRequest, direction: direction, timestampMs: timestampMs diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift new file mode 100644 index 000000000..019b19829 --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Contacts.swift @@ -0,0 +1,591 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtil +import SessionUtilitiesKit + +// MARK: - Size Restrictions + +public extension SessionUtil { + static var libSessionMaxNameByteLength: Int { CONTACT_MAX_NAME_LENGTH } + static var libSessionMaxNicknameByteLength: Int { CONTACT_MAX_NAME_LENGTH } + static var libSessionMaxProfileUrlByteLength: Int { PROFILE_PIC_MAX_URL_LENGTH } +} + +// MARK: - Contacts Handling + +internal extension SessionUtil { + static let columnsRelatedToContacts: [ColumnExpression] = [ + Contact.Columns.isApproved, + Contact.Columns.isBlocked, + Contact.Columns.didApproveMe, + Profile.Columns.name, + Profile.Columns.nickname, + Profile.Columns.profilePictureUrl, + Profile.Columns.profileEncryptionKey + ] + + // MARK: - Incoming Changes + + static func handleContactsUpdate( + _ db: Database, + in conf: UnsafeMutablePointer?, + mergeNeedsDump: Bool, + latestConfigSentTimestampMs: Int64 + ) throws { + guard mergeNeedsDump else { return } + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + // The current users contact data is handled separately so exclude it if it's present (as that's + // actually a bug) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let targetContactData: [String: ContactData] = try extractContacts( + from: conf, + latestConfigSentTimestampMs: latestConfigSentTimestampMs + ).filter { $0.key != userPublicKey } + + // Since we don't sync 100% of the data stored against the contact and profile objects we + // need to only update the data we do have to ensure we don't overwrite anything that doesn't + // get synced + try targetContactData + .forEach { sessionId, data in + // 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 (the DB + // observation system can't differ between update calls which do and don't change anything) + let contact: Contact = Contact.fetchOrCreate(db, id: sessionId) + let profile: Profile = Profile.fetchOrCreate(db, id: sessionId) + let profileNameShouldBeUpdated: Bool = ( + !data.profile.name.isEmpty && + profile.name != data.profile.name && + profile.lastNameUpdate < data.profile.lastNameUpdate + ) + let profilePictureShouldBeUpdated: Bool = ( + ( + profile.profilePictureUrl != data.profile.profilePictureUrl || + profile.profileEncryptionKey != data.profile.profileEncryptionKey + ) && + profile.lastProfilePictureUpdate < data.profile.lastProfilePictureUpdate + ) + + if + profileNameShouldBeUpdated || + profile.nickname != data.profile.nickname || + profilePictureShouldBeUpdated + { + try profile.save(db) + try Profile + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + [ + (!profileNameShouldBeUpdated ? nil : + Profile.Columns.name.set(to: data.profile.name) + ), + (!profileNameShouldBeUpdated ? nil : + Profile.Columns.lastNameUpdate.set(to: data.profile.lastNameUpdate) + ), + (profile.nickname == data.profile.nickname ? nil : + Profile.Columns.nickname.set(to: data.profile.nickname) + ), + (profile.profilePictureUrl != data.profile.profilePictureUrl ? nil : + Profile.Columns.profilePictureUrl.set(to: data.profile.profilePictureUrl) + ), + (profile.profileEncryptionKey != data.profile.profileEncryptionKey ? nil : + Profile.Columns.profileEncryptionKey.set(to: data.profile.profileEncryptionKey) + ), + (!profilePictureShouldBeUpdated ? nil : + Profile.Columns.lastProfilePictureUpdate.set(to: data.profile.lastProfilePictureUpdate) + ) + ].compactMap { $0 } + ) + } + + /// 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 + (contact.isApproved != data.contact.isApproved) || + (contact.isBlocked != data.contact.isBlocked) || + (contact.didApproveMe != data.contact.didApproveMe) + { + try contact.save(db) + try Contact + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + [ + (!data.contact.isApproved || contact.isApproved == data.contact.isApproved ? nil : + Contact.Columns.isApproved.set(to: true) + ), + (contact.isBlocked == data.contact.isBlocked ? nil : + Contact.Columns.isBlocked.set(to: data.contact.isBlocked) + ), + (!data.contact.didApproveMe || contact.didApproveMe == data.contact.didApproveMe ? nil : + Contact.Columns.didApproveMe.set(to: true) + ) + ].compactMap { $0 } + ) + } + + /// If the contact's `hidden` flag doesn't match the visibility of their conversation then create/delete the + /// associated contact conversation accordingly + let threadInfo: PriorityVisibilityInfo? = try? SessionThread + .filter(id: sessionId) + .select(.id, .variant, .pinnedPriority, .shouldBeVisible) + .asRequest(of: PriorityVisibilityInfo.self) + .fetchOne(db) + let threadExists: Bool = (threadInfo != nil) + let updatedShouldBeVisible: Bool = SessionUtil.shouldBeVisible(priority: data.priority) + + switch (updatedShouldBeVisible, threadExists) { + case (false, true): + SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: [sessionId]) + + try SessionThread + .deleteOrLeave( + db, + threadId: sessionId, + threadVariant: .contact, + groupLeaveType: .forced, + calledFromConfigHandling: true + ) + + case (true, false): + try SessionThread( + id: sessionId, + variant: .contact, + creationDateTimestamp: data.created, + shouldBeVisible: true, + pinnedPriority: data.priority + ).save(db) + + case (true, true): + let changes: [ConfigColumnAssignment] = [ + (threadInfo?.shouldBeVisible == updatedShouldBeVisible ? nil : + SessionThread.Columns.shouldBeVisible.set(to: updatedShouldBeVisible) + ), + (threadInfo?.pinnedPriority == data.priority ? nil : + SessionThread.Columns.pinnedPriority.set(to: data.priority) + ) + ].compactMap { $0 } + + try SessionThread + .filter(id: sessionId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + changes + ) + + case (false, false): break + } + } + + /// Delete any contact/thread records which aren't in the config message + let syncedContactIds: [String] = targetContactData + .map { $0.key } + .appending(userPublicKey) + let contactIdsToRemove: [String] = try Contact + .filter(!syncedContactIds.contains(Contact.Columns.id)) + .select(.id) + .asRequest(of: String.self) + .fetchAll(db) + let threadIdsToRemove: [String] = try SessionThread + .filter(!syncedContactIds.contains(SessionThread.Columns.id)) + .filter(SessionThread.Columns.variant == SessionThread.Variant.contact) + .filter(!SessionThread.Columns.id.like("\(SessionId.Prefix.blinded15.rawValue)%")) + .filter(!SessionThread.Columns.id.like("\(SessionId.Prefix.blinded25.rawValue)%")) + .select(.id) + .asRequest(of: String.self) + .fetchAll(db) + + /// When the user opens a brand new conversation this creates a "draft conversation" which has a hidden thread but no + /// contact record, when we receive a contact update this "draft conversation" would be included in the + /// `threadIdsToRemove` which would result in the user getting kicked from the screen and the thread removed, we + /// want to avoid this (as it's essentially a bug) so find any conversations in this state and remove them from the list that + /// will be pruned + let threadT: TypedTableAlias = TypedTableAlias() + let contactT: TypedTableAlias = TypedTableAlias() + let draftConversationIds: [String] = try SQLRequest(""" + SELECT \(threadT[.id]) + FROM \(SessionThread.self) + LEFT JOIN \(Contact.self) ON \(contactT[.id]) = \(threadT[.id]) + WHERE ( + \(SQL("\(threadT[.id]) IN \(threadIdsToRemove)")) AND + \(contactT[.id]) IS NULL + ) + """).fetchAll(db) + + /// Consolidate the ids which should be removed + let combinedIds: [String] = contactIdsToRemove + .appending(contentsOf: threadIdsToRemove) + .filter { !draftConversationIds.contains($0) } + + if !combinedIds.isEmpty { + SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: combinedIds) + + try Contact + .filter(ids: combinedIds) + .deleteAll(db) + + // Also need to remove any 'nickname' values since they are associated to contact data + try Profile + .filter(ids: combinedIds) + .updateAll( + db, + Profile.Columns.nickname.set(to: nil) + ) + + // Delete the one-to-one conversations associated to the contact + try SessionThread + .deleteOrLeave( + db, + threadIds: combinedIds, + threadVariant: .contact, + groupLeaveType: .forced, + calledFromConfigHandling: true + ) + + try SessionUtil.remove(db, volatileContactIds: combinedIds) + } + } + + // MARK: - Outgoing Changes + + static func upsert( + contactData: [SyncedContactInfo], + in conf: UnsafeMutablePointer? + ) throws { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + // The current users contact data doesn't need to sync so exclude it, we also don't want to sync + // blinded message requests so exclude those as well + let userPublicKey: String = getUserHexEncodedPublicKey() + let targetContacts: [SyncedContactInfo] = contactData + .filter { + $0.id != userPublicKey && + SessionId(from: $0.id)?.prefix == .standard + } + + // If we only updated the current user contact then no need to continue + guard !targetContacts.isEmpty else { return } + + // Update the name + try targetContacts + .forEach { info in + var sessionId: [CChar] = info.id.cArray.nullTerminated() + var contact: contacts_contact = contacts_contact() + guard contacts_get_or_construct(conf, &contact, &sessionId) else { + /// It looks like there are some situations where this object might not get created correctly (and + /// will throw due to the implicit unwrapping) as a result we put it in a guard and throw instead + SNLog("Unable to upsert contact to SessionUtil: \(SessionUtil.lastError(conf))") + throw SessionUtilError.getOrConstructFailedUnexpectedly + } + + // Assign all properties to match the updated contact (if there is one) + if let updatedContact: Contact = info.contact { + contact.approved = updatedContact.isApproved + contact.approved_me = updatedContact.didApproveMe + contact.blocked = updatedContact.isBlocked + + // If we were given a `created` timestamp then set it to the min between the current + // setting and the value (as long as the current setting isn't `0`) + if let created: Int64 = info.created.map({ Int64(floor($0)) }) { + contact.created = (contact.created > 0 ? min(contact.created, created) : created) + } + + // Store the updated contact (needs to happen before variables go out of scope) + contacts_set(conf, &contact) + } + + // Update the profile data (if there is one - users we have sent a message request to may + // not have profile info in certain situations) + if let updatedProfile: Profile = info.profile { + let oldAvatarUrl: String? = String(libSessionVal: contact.profile_pic.url) + let oldAvatarKey: Data? = Data( + libSessionVal: contact.profile_pic.key, + count: ProfileManager.avatarAES256KeyByteLength + ) + + contact.name = updatedProfile.name.toLibSession() + contact.nickname = updatedProfile.nickname.toLibSession() + contact.profile_pic.url = updatedProfile.profilePictureUrl.toLibSession() + contact.profile_pic.key = updatedProfile.profileEncryptionKey.toLibSession() + + // Download the profile picture if needed (this can be triggered within + // database reads/writes so dispatch the download to a separate queue to + // prevent blocking) + if + oldAvatarUrl != (updatedProfile.profilePictureUrl ?? "") || + oldAvatarKey != (updatedProfile.profileEncryptionKey ?? Data(repeating: 0, count: ProfileManager.avatarAES256KeyByteLength)) + { + DispatchQueue.global(qos: .background).async { + ProfileManager.downloadAvatar(for: updatedProfile) + } + } + + // Store the updated contact (needs to happen before variables go out of scope) + contacts_set(conf, &contact) + } + + // Store the updated contact (can't be sure if we made any changes above) + contact.priority = (info.priority ?? contact.priority) + contacts_set(conf, &contact) + } + } +} + +// MARK: - Outgoing Changes + +internal extension SessionUtil { + static func updatingContacts(_ db: Database, _ updated: [T]) throws -> [T] { + guard let updatedContacts: [Contact] = updated as? [Contact] else { throw StorageError.generic } + + // The current users contact data doesn't need to sync so exclude it, we also don't want to sync + // blinded message requests so exclude those as well + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let targetContacts: [Contact] = updatedContacts + .filter { + $0.id != userPublicKey && + SessionId(from: $0.id)?.prefix == .standard + } + + // If we only updated the current user contact then no need to continue + guard !targetContacts.isEmpty else { return updated } + + try SessionUtil.performAndPushChange( + db, + for: .contacts, + publicKey: userPublicKey + ) { conf in + // When inserting new contacts (or contacts with invalid profile data) we want + // to add any valid profile information we have so identify if any of the updated + // contacts are new/invalid, and if so, fetch any profile data we have for them + let newContactIds: [String] = targetContacts + .compactMap { contactData -> String? in + var cContactId: [CChar] = contactData.id.cArray.nullTerminated() + var contact: contacts_contact = contacts_contact() + + guard + contacts_get(conf, &contact, &cContactId), + String(libSessionVal: contact.name, nullIfEmpty: true) != nil + else { return contactData.id } + + return nil + } + let newProfiles: [String: Profile] = try Profile + .fetchAll(db, ids: newContactIds) + .reduce(into: [:]) { result, next in result[next.id] = next } + + // Upsert the updated contact data + try SessionUtil + .upsert( + contactData: targetContacts + .map { contact in + SyncedContactInfo( + id: contact.id, + contact: contact, + profile: newProfiles[contact.id] + ) + }, + in: conf + ) + } + + return updated + } + + static func updatingProfiles(_ db: Database, _ updated: [T]) throws -> [T] { + guard let updatedProfiles: [Profile] = updated as? [Profile] else { throw StorageError.generic } + + // We should only sync profiles which are associated to contact data to avoid including profiles + // for random people in community conversations so filter out any profiles which don't have an + // associated contact + let existingContactIds: [String] = (try? Contact + .filter(ids: updatedProfiles.map { $0.id }) + .select(.id) + .asRequest(of: String.self) + .fetchAll(db)) + .defaulting(to: []) + + // If none of the profiles are associated with existing contacts then ignore the changes (no need + // to do a config sync) + guard !existingContactIds.isEmpty else { return updated } + + // Get the user public key (updating their profile is handled separately + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let targetProfiles: [Profile] = updatedProfiles + .filter { + $0.id != userPublicKey && + SessionId(from: $0.id)?.prefix == .standard && + existingContactIds.contains($0.id) + } + + // Update the user profile first (if needed) + if let updatedUserProfile: Profile = updatedProfiles.first(where: { $0.id == userPublicKey }) { + try SessionUtil.performAndPushChange( + db, + for: .userProfile, + publicKey: userPublicKey + ) { conf in + try SessionUtil.update( + profile: updatedUserProfile, + in: conf + ) + } + } + + try SessionUtil.performAndPushChange( + db, + for: .contacts, + publicKey: userPublicKey + ) { conf in + try SessionUtil + .upsert( + contactData: targetProfiles + .map { SyncedContactInfo(id: $0.id, profile: $0) }, + in: conf + ) + } + + return updated + } +} + +// MARK: - External Outgoing Changes + +public extension SessionUtil { + static func hide(_ db: Database, contactIds: [String]) throws { + try SessionUtil.performAndPushChange( + db, + for: .contacts, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + // Mark the contacts as hidden + try SessionUtil.upsert( + contactData: contactIds + .map { + SyncedContactInfo( + id: $0, + priority: SessionUtil.hiddenPriority + ) + }, + in: conf + ) + } + } + + static func remove(_ db: Database, contactIds: [String]) throws { + guard !contactIds.isEmpty else { return } + + try SessionUtil.performAndPushChange( + db, + for: .contacts, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + contactIds.forEach { sessionId in + var cSessionId: [CChar] = sessionId.cArray.nullTerminated() + + // Don't care if the contact doesn't exist + contacts_erase(conf, &cSessionId) + } + } + } +} + +// MARK: - SyncedContactInfo + +extension SessionUtil { + struct SyncedContactInfo { + let id: String + let contact: Contact? + let profile: Profile? + let priority: Int32? + let created: TimeInterval? + + init( + id: String, + contact: Contact? = nil, + profile: Profile? = nil, + priority: Int32? = nil, + created: TimeInterval? = nil + ) { + self.id = id + self.contact = contact + self.profile = profile + self.priority = priority + self.created = created + } + } +} + +// MARK: - ContactData + +private struct ContactData { + let contact: Contact + let profile: Profile + let priority: Int32 + let created: TimeInterval +} + +// MARK: - ThreadCount + +private struct ThreadCount: Codable, FetchableRecord { + let id: String + let interactionCount: Int +} + +// MARK: - Convenience + +private extension SessionUtil { + static func extractContacts( + from conf: UnsafeMutablePointer?, + latestConfigSentTimestampMs: Int64 + ) throws -> [String: ContactData] { + var infiniteLoopGuard: Int = 0 + var result: [String: ContactData] = [:] + var contact: contacts_contact = contacts_contact() + let contactIterator: UnsafeMutablePointer = contacts_iterator_new(conf) + + while !contacts_iterator_done(contactIterator, &contact) { + try SessionUtil.checkLoopLimitReached(&infiniteLoopGuard, for: .contacts) + + let contactId: String = String(cString: withUnsafeBytes(of: contact.session_id) { [UInt8]($0) } + .map { CChar($0) } + .nullTerminated() + ) + let contactResult: Contact = Contact( + id: contactId, + isApproved: contact.approved, + isBlocked: contact.blocked, + didApproveMe: contact.approved_me + ) + let profilePictureUrl: String? = String(libSessionVal: contact.profile_pic.url, nullIfEmpty: true) + let profileResult: Profile = Profile( + id: contactId, + name: String(libSessionVal: contact.name), + lastNameUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000), + nickname: String(libSessionVal: contact.nickname, nullIfEmpty: true), + profilePictureUrl: profilePictureUrl, + profileEncryptionKey: (profilePictureUrl == nil ? nil : + Data( + libSessionVal: contact.profile_pic.key, + count: ProfileManager.avatarAES256KeyByteLength + ) + ), + lastProfilePictureUpdate: (TimeInterval(latestConfigSentTimestampMs) / 1000) + ) + + result[contactId] = ContactData( + contact: contactResult, + profile: profileResult, + priority: contact.priority, + created: TimeInterval(contact.created) + ) + contacts_iterator_advance(contactIterator) + } + contacts_iterator_free(contactIterator) // Need to free the iterator + + return result + } +} diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift new file mode 100644 index 000000000..7e83611db --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+ConvoInfoVolatile.swift @@ -0,0 +1,608 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtil +import SessionUtilitiesKit + +internal extension SessionUtil { + static let columnsRelatedToConvoInfoVolatile: [ColumnExpression] = [ + // Note: We intentionally exclude 'Interaction.Columns.wasRead' from here as we want to + // manually manage triggering config updates from marking as read + SessionThread.Columns.markedAsUnread + ] + + // MARK: - Incoming Changes + + static func handleConvoInfoVolatileUpdate( + _ db: Database, + in conf: UnsafeMutablePointer?, + mergeNeedsDump: Bool + ) throws { + guard mergeNeedsDump else { return } + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + // Get the volatile thread info from the conf and local conversations + let volatileThreadInfo: [VolatileThreadInfo] = try extractConvoVolatileInfo(from: conf) + let localVolatileThreadInfo: [String: VolatileThreadInfo] = VolatileThreadInfo.fetchAll(db) + .reduce(into: [:]) { result, next in result[next.threadId] = next } + + // Map the volatileThreadInfo, upserting any changes and returning a list of local changes + // which should override any synced changes (eg. 'lastReadTimestampMs') + let newerLocalChanges: [VolatileThreadInfo] = try volatileThreadInfo + .compactMap { threadInfo -> VolatileThreadInfo? in + // Note: A normal 'openGroupId' isn't lowercased but the volatile conversation + // info will always be lowercase so we need to fetch the "proper" threadId (in + // order to be able to update the corrent database entries) + guard + let threadId: String = try? SessionThread + .select(.id) + .filter(SessionThread.Columns.id.lowercased == threadInfo.threadId) + .asRequest(of: String.self) + .fetchOne(db) + else { return nil } + + + // Get the existing local state for the thread + let localThreadInfo: VolatileThreadInfo? = localVolatileThreadInfo[threadId] + + // Update the thread 'markedAsUnread' state + if + let markedAsUnread: Bool = threadInfo.changes.markedAsUnread, + markedAsUnread != (localThreadInfo?.changes.markedAsUnread ?? false) + { + try SessionThread + .filter(id: threadId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + SessionThread.Columns.markedAsUnread.set(to: markedAsUnread) + ) + } + + // If the device has a more recent read interaction then return the info so we can + // update the cached config state accordingly + guard + let lastReadTimestampMs: Int64 = threadInfo.changes.lastReadTimestampMs, + lastReadTimestampMs >= (localThreadInfo?.changes.lastReadTimestampMs ?? 0) + else { + // We only want to return the 'lastReadTimestampMs' change, since the local state + // should win in that case, so ignore all others + return localThreadInfo? + .filterChanges { change in + switch change { + case .lastReadTimestampMs: return true + default: return false + } + } + } + + // Mark all older interactions as read + try Interaction + .filter( + Interaction.Columns.threadId == threadId && + Interaction.Columns.timestampMs <= lastReadTimestampMs && + Interaction.Columns.wasRead == false + ) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + Interaction.Columns.wasRead.set(to: true) + ) + return nil + } + + // If there are no newer local last read timestamps then just return the mergeResult + guard !newerLocalChanges.isEmpty else { return } + + try upsert( + convoInfoVolatileChanges: newerLocalChanges, + in: conf + ) + } + + static func upsert( + convoInfoVolatileChanges: [VolatileThreadInfo], + in conf: UnsafeMutablePointer? + ) throws { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + // Exclude any invalid thread info + let validChanges: [VolatileThreadInfo] = convoInfoVolatileChanges + .filter { info in + switch info.variant { + case .contact: + // FIXME: libSession V1 doesn't sync volatileThreadInfo for blinded message requests + guard SessionId(from: info.threadId)?.prefix == .standard else { return false } + + return true + + default: return true + } + } + + try validChanges.forEach { threadInfo in + var cThreadId: [CChar] = threadInfo.threadId.cArray.nullTerminated() + + switch threadInfo.variant { + case .contact: + var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() + + guard convo_info_volatile_get_or_construct_1to1(conf, &oneToOne, &cThreadId) else { + /// It looks like there are some situations where this object might not get created correctly (and + /// will throw due to the implicit unwrapping) as a result we put it in a guard and throw instead + SNLog("Unable to upsert contact volatile info to SessionUtil: \(SessionUtil.lastError(conf))") + throw SessionUtilError.getOrConstructFailedUnexpectedly + } + + threadInfo.changes.forEach { change in + switch change { + case .lastReadTimestampMs(let lastReadMs): + oneToOne.last_read = max(oneToOne.last_read, lastReadMs) + + case .markedAsUnread(let unread): + oneToOne.unread = unread + } + } + convo_info_volatile_set_1to1(conf, &oneToOne) + + case .legacyGroup: + var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + + guard convo_info_volatile_get_or_construct_legacy_group(conf, &legacyGroup, &cThreadId) else { + /// It looks like there are some situations where this object might not get created correctly (and + /// will throw due to the implicit unwrapping) as a result we put it in a guard and throw instead + SNLog("Unable to upsert legacy group volatile info to SessionUtil: \(SessionUtil.lastError(conf))") + throw SessionUtilError.getOrConstructFailedUnexpectedly + } + + threadInfo.changes.forEach { change in + switch change { + case .lastReadTimestampMs(let lastReadMs): + legacyGroup.last_read = max(legacyGroup.last_read, lastReadMs) + + case .markedAsUnread(let unread): + legacyGroup.unread = unread + } + } + convo_info_volatile_set_legacy_group(conf, &legacyGroup) + + case .community: + guard + var cBaseUrl: [CChar] = threadInfo.openGroupUrlInfo?.server.cArray.nullTerminated(), + var cRoomToken: [CChar] = threadInfo.openGroupUrlInfo?.roomToken.cArray.nullTerminated(), + var cPubkey: [UInt8] = threadInfo.openGroupUrlInfo?.publicKey.bytes + else { + SNLog("Unable to create community conversation when updating last read timestamp due to missing URL info") + return + } + + var community: convo_info_volatile_community = convo_info_volatile_community() + + guard convo_info_volatile_get_or_construct_community(conf, &community, &cBaseUrl, &cRoomToken, &cPubkey) else { + /// It looks like there are some situations where this object might not get created correctly (and + /// will throw due to the implicit unwrapping) as a result we put it in a guard and throw instead + SNLog("Unable to upsert community volatile info to SessionUtil: \(SessionUtil.lastError(conf))") + throw SessionUtilError.getOrConstructFailedUnexpectedly + } + + threadInfo.changes.forEach { change in + switch change { + case .lastReadTimestampMs(let lastReadMs): + community.last_read = max(community.last_read, lastReadMs) + + case .markedAsUnread(let unread): + community.unread = unread + } + } + convo_info_volatile_set_community(conf, &community) + + case .group: return + } + } + } + + static func updateMarkedAsUnreadState( + _ db: Database, + threads: [SessionThread] + ) throws { + // If we have no updated threads then no need to continue + guard !threads.isEmpty else { return } + + let changes: [VolatileThreadInfo] = try threads.map { thread in + VolatileThreadInfo( + threadId: thread.id, + variant: thread.variant, + openGroupUrlInfo: (thread.variant != .community ? nil : + try OpenGroupUrlInfo.fetchOne(db, id: thread.id) + ), + changes: [.markedAsUnread(thread.markedAsUnread ?? false)] + ) + } + + try SessionUtil.performAndPushChange( + db, + for: .convoInfoVolatile, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + try upsert( + convoInfoVolatileChanges: changes, + in: conf + ) + } + } + + static func remove(_ db: Database, volatileContactIds: [String]) throws { + try SessionUtil.performAndPushChange( + db, + for: .convoInfoVolatile, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + volatileContactIds.forEach { contactId in + var cSessionId: [CChar] = contactId.cArray.nullTerminated() + + // Don't care if the data doesn't exist + convo_info_volatile_erase_1to1(conf, &cSessionId) + } + } + } + + static func remove(_ db: Database, volatileLegacyGroupIds: [String]) throws { + try SessionUtil.performAndPushChange( + db, + for: .convoInfoVolatile, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + volatileLegacyGroupIds.forEach { legacyGroupId in + var cLegacyGroupId: [CChar] = legacyGroupId.cArray.nullTerminated() + + // Don't care if the data doesn't exist + convo_info_volatile_erase_legacy_group(conf, &cLegacyGroupId) + } + } + } + + static func remove(_ db: Database, volatileCommunityInfo: [OpenGroupUrlInfo]) throws { + try SessionUtil.performAndPushChange( + db, + for: .convoInfoVolatile, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + volatileCommunityInfo.forEach { urlInfo in + var cBaseUrl: [CChar] = urlInfo.server.cArray.nullTerminated() + var cRoom: [CChar] = urlInfo.roomToken.cArray.nullTerminated() + + // Don't care if the data doesn't exist + convo_info_volatile_erase_community(conf, &cBaseUrl, &cRoom) + } + } + } +} + +// MARK: - External Outgoing Changes + +public extension SessionUtil { + static func syncThreadLastReadIfNeeded( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant, + lastReadTimestampMs: Int64 + ) throws { + try SessionUtil.performAndPushChange( + db, + for: .convoInfoVolatile, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + try upsert( + convoInfoVolatileChanges: [ + VolatileThreadInfo( + threadId: threadId, + variant: threadVariant, + openGroupUrlInfo: (threadVariant != .community ? nil : + try OpenGroupUrlInfo.fetchOne(db, id: threadId) + ), + changes: [.lastReadTimestampMs(lastReadTimestampMs)] + ) + ], + in: conf + ) + } + } + + static func timestampAlreadyRead( + threadId: String, + threadVariant: SessionThread.Variant, + timestampMs: Int64, + userPublicKey: String, + openGroup: OpenGroup? + ) -> Bool { + return SessionUtil + .config(for: .convoInfoVolatile, publicKey: userPublicKey) + .wrappedValue + .map { conf in + switch threadVariant { + case .contact: + var cThreadId: [CChar] = threadId.cArray.nullTerminated() + var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() + guard convo_info_volatile_get_1to1(conf, &oneToOne, &cThreadId) else { + return false + } + + return (oneToOne.last_read >= timestampMs) + + case .legacyGroup: + var cThreadId: [CChar] = threadId.cArray.nullTerminated() + var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + + guard convo_info_volatile_get_legacy_group(conf, &legacyGroup, &cThreadId) else { + return false + } + + return (legacyGroup.last_read >= timestampMs) + + case .community: + guard let openGroup: OpenGroup = openGroup else { return false } + + var cBaseUrl: [CChar] = openGroup.server.cArray.nullTerminated() + var cRoomToken: [CChar] = openGroup.roomToken.cArray.nullTerminated() + var convoCommunity: convo_info_volatile_community = convo_info_volatile_community() + + guard convo_info_volatile_get_community(conf, &convoCommunity, &cBaseUrl, &cRoomToken) else { + return false + } + + return (convoCommunity.last_read >= timestampMs) + + case .group: return false + } + } + .defaulting(to: false) // If we don't have a config then just assume it's unread + } +} + +// MARK: - VolatileThreadInfo + +public extension SessionUtil { + internal struct OpenGroupUrlInfo: FetchableRecord, Codable, Hashable { + let threadId: String + let server: String + let roomToken: String + let publicKey: String + + static func fetchOne(_ db: Database, id: String) throws -> OpenGroupUrlInfo? { + return try OpenGroup + .filter(id: id) + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchOne(db) + } + + static func fetchAll(_ db: Database, ids: [String]) throws -> [OpenGroupUrlInfo] { + return try OpenGroup + .filter(ids: ids) + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchAll(db) + } + + static func fetchAll(_ db: Database) throws -> [OpenGroupUrlInfo] { + return try OpenGroup + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchAll(db) + } + } + + struct VolatileThreadInfo { + enum Change { + case markedAsUnread(Bool) + case lastReadTimestampMs(Int64) + } + + let threadId: String + let variant: SessionThread.Variant + fileprivate let openGroupUrlInfo: OpenGroupUrlInfo? + let changes: [Change] + + fileprivate init( + threadId: String, + variant: SessionThread.Variant, + openGroupUrlInfo: OpenGroupUrlInfo? = nil, + changes: [Change] + ) { + self.threadId = threadId + self.variant = variant + self.openGroupUrlInfo = openGroupUrlInfo + self.changes = changes + } + + // MARK: - Convenience + + func filterChanges(isIncluded: (Change) -> Bool) -> VolatileThreadInfo { + return VolatileThreadInfo( + threadId: threadId, + variant: variant, + openGroupUrlInfo: openGroupUrlInfo, + changes: changes.filter(isIncluded) + ) + } + + static func fetchAll(_ db: Database? = nil, ids: [String]? = nil) -> [VolatileThreadInfo] { + guard let db: Database = db else { + return Storage.shared + .read { db in fetchAll(db, ids: ids) } + .defaulting(to: []) + } + + struct FetchedInfo: FetchableRecord, Codable, Hashable { + let id: String + let variant: SessionThread.Variant + let markedAsUnread: Bool? + let timestampMs: Int64? + let server: String? + let roomToken: String? + let publicKey: String? + } + + let thread: TypedTableAlias = TypedTableAlias() + let interaction: TypedTableAlias = TypedTableAlias() + let openGroup: TypedTableAlias = TypedTableAlias() + let timestampMsLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + let request: SQLRequest = """ + SELECT + \(thread[.id]), + \(thread[.variant]), + \(thread[.markedAsUnread]), + \(interaction[.timestampMs]), + \(openGroup[.server]), + \(openGroup[.roomToken]), + \(openGroup[.publicKey]) + + FROM \(SessionThread.self) + LEFT JOIN ( + SELECT + \(interaction[.threadId]), + MAX(\(interaction[.timestampMs])) AS \(timestampMsLiteral) + FROM \(Interaction.self) + WHERE ( + \(interaction[.wasRead]) = true AND + -- Note: Due to the complexity of how call messages are handled and the short + -- duration they exist in the swarm, we have decided to exclude trying to + -- include them when syncing the read status of conversations (they are also + -- implemented differently between platforms so including them could be a + -- significant amount of work) + \(SQL("\(interaction[.variant]) IN \(Interaction.Variant.variantsToIncrementUnreadCount.filter { $0 != .infoCall })")) + ) + GROUP BY \(interaction[.threadId]) + ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) + \(ids == nil ? SQL("") : + "WHERE \(SQL("\(thread[.id]) IN \(ids ?? [])"))" + ) + GROUP BY \(thread[.id]) + """ + + return ((try? request.fetchAll(db)) ?? []) + .map { threadInfo in + VolatileThreadInfo( + threadId: threadInfo.id, + variant: threadInfo.variant, + openGroupUrlInfo: { + guard + let server: String = threadInfo.server, + let roomToken: String = threadInfo.roomToken, + let publicKey: String = threadInfo.publicKey + else { return nil } + + return OpenGroupUrlInfo( + threadId: threadInfo.id, + server: server, + roomToken: roomToken, + publicKey: publicKey + ) + }(), + changes: [ + .markedAsUnread(threadInfo.markedAsUnread ?? false), + .lastReadTimestampMs(threadInfo.timestampMs ?? 0) + ] + ) + } + } + } + + internal static func extractConvoVolatileInfo( + from conf: UnsafeMutablePointer? + ) throws -> [VolatileThreadInfo] { + var infiniteLoopGuard: Int = 0 + var result: [VolatileThreadInfo] = [] + var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() + var community: convo_info_volatile_community = convo_info_volatile_community() + var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + let convoIterator: OpaquePointer = convo_info_volatile_iterator_new(conf) + + while !convo_info_volatile_iterator_done(convoIterator) { + try SessionUtil.checkLoopLimitReached(&infiniteLoopGuard, for: .convoInfoVolatile) + + if convo_info_volatile_it_is_1to1(convoIterator, &oneToOne) { + result.append( + VolatileThreadInfo( + threadId: String(libSessionVal: oneToOne.session_id), + variant: .contact, + changes: [ + .markedAsUnread(oneToOne.unread), + .lastReadTimestampMs(oneToOne.last_read) + ] + ) + ) + } + else if convo_info_volatile_it_is_community(convoIterator, &community) { + let server: String = String(libSessionVal: community.base_url) + let roomToken: String = String(libSessionVal: community.room) + let publicKey: String = Data( + libSessionVal: community.pubkey, + count: OpenGroup.pubkeyByteLength + ).toHexString() + + result.append( + VolatileThreadInfo( + threadId: OpenGroup.idFor(roomToken: roomToken, server: server), + variant: .community, + openGroupUrlInfo: OpenGroupUrlInfo( + threadId: OpenGroup.idFor(roomToken: roomToken, server: server), + server: server, + roomToken: roomToken, + publicKey: publicKey + ), + changes: [ + .markedAsUnread(community.unread), + .lastReadTimestampMs(community.last_read) + ] + ) + ) + } + else if convo_info_volatile_it_is_legacy_group(convoIterator, &legacyGroup) { + result.append( + VolatileThreadInfo( + threadId: String(libSessionVal: legacyGroup.group_id), + variant: .legacyGroup, + changes: [ + .markedAsUnread(legacyGroup.unread), + .lastReadTimestampMs(legacyGroup.last_read) + ] + ) + ) + } + else { + SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update") + } + + convo_info_volatile_iterator_advance(convoIterator) + } + convo_info_volatile_iterator_free(convoIterator) // Need to free the iterator + + return result + } +} + +fileprivate extension [SessionUtil.VolatileThreadInfo.Change] { + var markedAsUnread: Bool? { + for change in self { + switch change { + case .markedAsUnread(let value): return value + default: continue + } + } + + return nil + } + + var lastReadTimestampMs: Int64? { + for change in self { + switch change { + case .lastReadTimestampMs(let value): return value + default: continue + } + } + + return nil + } +} + diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift new file mode 100644 index 000000000..909ea9ce7 --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+Shared.swift @@ -0,0 +1,472 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import SessionUIKit +import SessionSnodeKit +import SessionUtil +import SessionUtilitiesKit + +// MARK: - Convenience + +internal extension SessionUtil { + /// This is a buffer period within which we will process messages which would result in a config change, any message which would normally + /// result in a config change which was sent before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not + /// actually have it's changes applied (info messages would still be inserted though) + static let configChangeBufferPeriod: TimeInterval = (2 * 60) + + static let columnsRelatedToThreads: [ColumnExpression] = [ + SessionThread.Columns.pinnedPriority, + SessionThread.Columns.shouldBeVisible + ] + + static func assignmentsRequireConfigUpdate(_ assignments: [ConfigColumnAssignment]) -> Bool { + let targetColumns: Set = Set(assignments.map { ColumnKey($0.column) }) + let allColumnsThatTriggerConfigUpdate: Set = [] + .appending(contentsOf: columnsRelatedToUserProfile) + .appending(contentsOf: columnsRelatedToContacts) + .appending(contentsOf: columnsRelatedToConvoInfoVolatile) + .appending(contentsOf: columnsRelatedToUserGroups) + .appending(contentsOf: columnsRelatedToThreads) + .map { ColumnKey($0) } + .asSet() + + return !allColumnsThatTriggerConfigUpdate.isDisjoint(with: targetColumns) + } + + /// A `0` `priority` value indicates visible, but not pinned + static let visiblePriority: Int32 = 0 + + /// A negative `priority` value indicates hidden + static let hiddenPriority: Int32 = -1 + + static func shouldBeVisible(priority: Int32) -> Bool { + return (priority >= SessionUtil.visiblePriority) + } + + static func performAndPushChange( + _ db: Database, + for variant: ConfigDump.Variant, + publicKey: String, + change: (UnsafeMutablePointer?) throws -> () + ) throws { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled(db, ignoreRequirementsForRunningMigrations: true) else { return } + + // Since we are doing direct memory manipulation we are using an `Atomic` + // type which has blocking access in it's `mutate` closure + let needsPush: Bool + + do { + needsPush = try SessionUtil + .config(for: variant, publicKey: publicKey) + .mutate { conf in + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + // Peform the change + try change(conf) + + // If we don't need to dump the data the we can finish early + guard config_needs_dump(conf) else { return config_needs_push(conf) } + + try SessionUtil.createDump( + conf: conf, + for: variant, + publicKey: publicKey, + timestampMs: SnodeAPI.currentOffsetTimestampMs() + )?.save(db) + + return config_needs_push(conf) + } + } + catch { + SNLog("[libSession] Failed to update/dump updated \(variant) config data due to error: \(error)") + throw error + } + + // Make sure we need a push before scheduling one + guard needsPush else { return } + + db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(publicKey)) { db in + ConfigurationSyncJob.enqueue(db, publicKey: publicKey) + } + } + + @discardableResult static func updatingThreads(_ db: Database, _ updated: [T]) throws -> [T] { + guard let updatedThreads: [SessionThread] = updated as? [SessionThread] else { + throw StorageError.generic + } + + // If we have no updated threads then no need to continue + guard !updatedThreads.isEmpty else { return updated } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let groupedThreads: [SessionThread.Variant: [SessionThread]] = updatedThreads + .grouped(by: \.variant) + let urlInfo: [String: OpenGroupUrlInfo] = try OpenGroupUrlInfo + .fetchAll(db, ids: updatedThreads.map { $0.id }) + .reduce(into: [:]) { result, next in result[next.threadId] = next } + + // Update the unread state for the threads first (just in case that's what changed) + try SessionUtil.updateMarkedAsUnreadState(db, threads: updatedThreads) + + // Then update the `hidden` and `priority` values + try groupedThreads.forEach { variant, threads in + switch variant { + case .contact: + // If the 'Note to Self' conversation is pinned then we need to custom handle it + // first as it's part of the UserProfile config + if let noteToSelf: SessionThread = threads.first(where: { $0.id == userPublicKey }) { + try SessionUtil.performAndPushChange( + db, + for: .userProfile, + publicKey: userPublicKey + ) { conf in + try SessionUtil.updateNoteToSelf( + priority: { + guard noteToSelf.shouldBeVisible else { return SessionUtil.hiddenPriority } + + return noteToSelf.pinnedPriority + .map { Int32($0 == 0 ? SessionUtil.visiblePriority : max($0, 1)) } + .defaulting(to: SessionUtil.visiblePriority) + }(), + in: conf + ) + } + } + + // Remove the 'Note to Self' convo from the list for updating contact priorities + let remainingThreads: [SessionThread] = threads.filter { $0.id != userPublicKey } + + guard !remainingThreads.isEmpty else { return } + + try SessionUtil.performAndPushChange( + db, + for: .contacts, + publicKey: userPublicKey + ) { conf in + try SessionUtil.upsert( + contactData: remainingThreads + .map { thread in + SyncedContactInfo( + id: thread.id, + priority: { + guard thread.shouldBeVisible else { return SessionUtil.hiddenPriority } + + return thread.pinnedPriority + .map { Int32($0 == 0 ? SessionUtil.visiblePriority : max($0, 1)) } + .defaulting(to: SessionUtil.visiblePriority) + }() + ) + }, + in: conf + ) + } + + case .community: + try SessionUtil.performAndPushChange( + db, + for: .userGroups, + publicKey: userPublicKey + ) { conf in + try SessionUtil.upsert( + communities: threads + .compactMap { thread -> CommunityInfo? in + urlInfo[thread.id].map { urlInfo in + CommunityInfo( + urlInfo: urlInfo, + priority: thread.pinnedPriority + .map { Int32($0 == 0 ? SessionUtil.visiblePriority : max($0, 1)) } + .defaulting(to: SessionUtil.visiblePriority) + ) + } + }, + in: conf + ) + } + + case .legacyGroup: + try SessionUtil.performAndPushChange( + db, + for: .userGroups, + publicKey: userPublicKey + ) { conf in + try SessionUtil.upsert( + legacyGroups: threads + .map { thread in + LegacyGroupInfo( + id: thread.id, + priority: thread.pinnedPriority + .map { Int32($0 == 0 ? SessionUtil.visiblePriority : max($0, 1)) } + .defaulting(to: SessionUtil.visiblePriority) + ) + }, + in: conf + ) + } + + case .group: + break + } + } + + return updated + } + + static func kickFromConversationUIIfNeeded(removedThreadIds: [String]) { + guard !removedThreadIds.isEmpty else { return } + + // If the user is currently navigating somewhere within the view hierarchy of a conversation + // we just deleted then return to the home screen + DispatchQueue.main.async { + guard + let rootViewController: UIViewController = CurrentAppContext().mainWindow?.rootViewController, + let topBannerController: TopBannerController = (rootViewController as? TopBannerController), + !topBannerController.children.isEmpty, + let navController: UINavigationController = topBannerController.children[0] as? UINavigationController + else { return } + + // Extract the ones which will respond to SessionUtil changes + let targetViewControllers: [any SessionUtilRespondingViewController] = navController + .viewControllers + .compactMap { $0 as? SessionUtilRespondingViewController } + let presentedNavController: UINavigationController? = (navController.presentedViewController as? UINavigationController) + let presentedTargetViewControllers: [any SessionUtilRespondingViewController] = (presentedNavController? + .viewControllers + .compactMap { $0 as? SessionUtilRespondingViewController }) + .defaulting(to: []) + + // Make sure we have a conversation list and that one of the removed conversations are + // in the nav hierarchy + let rootNavControllerNeedsPop: Bool = ( + targetViewControllers.count > 1 && + targetViewControllers.contains(where: { $0.isConversationList }) && + targetViewControllers.contains(where: { $0.isConversation(in: removedThreadIds) }) + ) + let presentedNavControllerNeedsPop: Bool = ( + presentedTargetViewControllers.count > 1 && + presentedTargetViewControllers.contains(where: { $0.isConversationList }) && + presentedTargetViewControllers.contains(where: { $0.isConversation(in: removedThreadIds) }) + ) + + // Force the UI to refresh if needed (most screens should do this automatically via database + // observation, but a couple of screens don't so need to be done manually) + targetViewControllers + .appending(contentsOf: presentedTargetViewControllers) + .filter { $0.isConversationList } + .forEach { $0.forceRefreshIfNeeded() } + + switch (rootNavControllerNeedsPop, presentedNavControllerNeedsPop) { + case (true, false): + // Return to the conversation list as the removed conversation will be invalid + guard + let targetViewController: UIViewController = navController.viewControllers + .last(where: { viewController in + ((viewController as? SessionUtilRespondingViewController)?.isConversationList) + .defaulting(to: false) + }) + else { return } + + if navController.presentedViewController != nil { + navController.dismiss(animated: false) { + navController.popToViewController(targetViewController, animated: true) + } + } + else { + navController.popToViewController(targetViewController, animated: true) + } + + case (false, true): + // Return to the conversation list as the removed conversation will be invalid + guard + let targetViewController: UIViewController = presentedNavController? + .viewControllers + .last(where: { viewController in + ((viewController as? SessionUtilRespondingViewController)?.isConversationList) + .defaulting(to: false) + }) + else { return } + + if presentedNavController?.presentedViewController != nil { + presentedNavController?.dismiss(animated: false) { + presentedNavController?.popToViewController(targetViewController, animated: true) + } + } + else { + presentedNavController?.popToViewController(targetViewController, animated: true) + } + + default: break + } + } + } + + static func canPerformChange( + _ db: Database, + threadId: String, + targetConfig: ConfigDump.Variant, + changeTimestampMs: Int64 + ) -> Bool { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled(db) else { return true } + + let targetPublicKey: String = { + switch targetConfig { + default: return getUserHexEncodedPublicKey(db) + } + }() + + let configDumpTimestampMs: Int64 = (try? ConfigDump + .filter( + ConfigDump.Columns.variant == targetConfig && + ConfigDump.Columns.publicKey == targetPublicKey + ) + .select(.timestampMs) + .asRequest(of: Int64.self) + .fetchOne(db)) + .defaulting(to: 0) + + // Ensure the change occurred after the last config message was handled (minus the buffer period) + return (changeTimestampMs >= (configDumpTimestampMs - Int64(SessionUtil.configChangeBufferPeriod * 1000))) + } + + static func checkLoopLimitReached(_ loopCounter: inout Int, for variant: ConfigDump.Variant, maxLoopCount: Int = 50000) throws { + loopCounter += 1 + + guard loopCounter < maxLoopCount else { + SNLog("[libSession] Got stuck in infinite loop processing '\(variant.configMessageKind.description)' data") + throw SessionUtilError.processingLoopLimitReached + } + } +} + +// MARK: - External Outgoing Changes + +public extension SessionUtil { + static func conversationInConfig( + _ db: Database? = nil, + threadId: String, + threadVariant: SessionThread.Variant, + visibleOnly: Bool + ) -> Bool { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled(db) else { return true } + + let userPublicKey: String = getUserHexEncodedPublicKey() + let configVariant: ConfigDump.Variant = { + switch threadVariant { + case .contact: return (threadId == userPublicKey ? .userProfile : .contacts) + case .legacyGroup, .group, .community: return .userGroups + } + }() + + return SessionUtil + .config(for: configVariant, publicKey: userPublicKey) + .wrappedValue + .map { conf in + var cThreadId: [CChar] = threadId.cArray.nullTerminated() + + switch threadVariant { + case .contact: + // The 'Note to Self' conversation is stored in the 'userProfile' config + guard threadId != userPublicKey else { + return ( + !visibleOnly || + SessionUtil.shouldBeVisible(priority: user_profile_get_nts_priority(conf)) + ) + } + + var contact: contacts_contact = contacts_contact() + + guard contacts_get(conf, &contact, &cThreadId) else { return false } + + /// If the user opens a conversation with an existing contact but doesn't send them a message + /// then the one-to-one conversation should remain hidden so we want to delete the `SessionThread` + /// when leaving the conversation + return (!visibleOnly || SessionUtil.shouldBeVisible(priority: contact.priority)) + + case .community: + let maybeUrlInfo: OpenGroupUrlInfo? = Storage.shared + .read { db in try OpenGroupUrlInfo.fetchAll(db, ids: [threadId]) }? + .first + + guard let urlInfo: OpenGroupUrlInfo = maybeUrlInfo else { return false } + + var cBaseUrl: [CChar] = urlInfo.server.cArray.nullTerminated() + var cRoom: [CChar] = urlInfo.roomToken.cArray.nullTerminated() + var community: ugroups_community_info = ugroups_community_info() + + /// Not handling the `hidden` behaviour for communities so just indicate the existence + return user_groups_get_community(conf, &community, &cBaseUrl, &cRoom) + + case .legacyGroup: + let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) + + /// Not handling the `hidden` behaviour for legacy groups so just indicate the existence + if groupInfo != nil { + ugroups_legacy_group_free(groupInfo) + return true + } + + return false + + case .group: + return false + } + } + .defaulting(to: false) + } +} + +// MARK: - ColumnKey + +internal extension SessionUtil { + struct ColumnKey: Equatable, Hashable { + let sourceType: Any.Type + let columnName: String + + init(_ column: ColumnExpression) { + self.sourceType = type(of: column) + self.columnName = column.name + } + + func hash(into hasher: inout Hasher) { + ObjectIdentifier(sourceType).hash(into: &hasher) + columnName.hash(into: &hasher) + } + + static func == (lhs: ColumnKey, rhs: ColumnKey) -> Bool { + return ( + lhs.sourceType == rhs.sourceType && + lhs.columnName == rhs.columnName + ) + } + } +} + +// MARK: - PriorityVisibilityInfo + +extension SessionUtil { + struct PriorityVisibilityInfo: Codable, FetchableRecord, Identifiable { + let id: String + let variant: SessionThread.Variant + let pinnedPriority: Int32? + let shouldBeVisible: Bool + } +} + +// MARK: - SessionUtilRespondingViewController + +public protocol SessionUtilRespondingViewController { + var isConversationList: Bool { get } + + func isConversation(in threadIds: [String]) -> Bool + func forceRefreshIfNeeded() +} + +public extension SessionUtilRespondingViewController { + var isConversationList: Bool { false } + + func isConversation(in threadIds: [String]) -> Bool { return false } + func forceRefreshIfNeeded() {} +} diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift new file mode 100644 index 000000000..a10e617be --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserGroups.swift @@ -0,0 +1,886 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import SessionUtil +import SessionUtilitiesKit +import SessionSnodeKit + +// MARK: - Size Restrictions + +public extension SessionUtil { + static var libSessionMaxGroupNameByteLength: Int { GROUP_NAME_MAX_LENGTH } + static var libSessionMaxGroupBaseUrlByteLength: Int { COMMUNITY_BASE_URL_MAX_LENGTH } + static var libSessionMaxGroupFullUrlByteLength: Int { COMMUNITY_FULL_URL_MAX_LENGTH } + static var libSessionMaxCommunityRoomByteLength: Int { COMMUNITY_ROOM_MAX_LENGTH } +} + +// MARK: - UserGroups Handling + +internal extension SessionUtil { + static let columnsRelatedToUserGroups: [ColumnExpression] = [ + ClosedGroup.Columns.name + ] + + // MARK: - Incoming Changes + + static func handleGroupsUpdate( + _ db: Database, + in conf: UnsafeMutablePointer?, + mergeNeedsDump: Bool, + latestConfigSentTimestampMs: Int64 + ) throws { + guard mergeNeedsDump else { return } + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + var infiniteLoopGuard: Int = 0 + var communities: [PrioritisedData] = [] + var legacyGroups: [LegacyGroupInfo] = [] + var community: ugroups_community_info = ugroups_community_info() + var legacyGroup: ugroups_legacy_group_info = ugroups_legacy_group_info() + let groupsIterator: OpaquePointer = user_groups_iterator_new(conf) + + while !user_groups_iterator_done(groupsIterator) { + try SessionUtil.checkLoopLimitReached(&infiniteLoopGuard, for: .userGroups) + + if user_groups_it_is_community(groupsIterator, &community) { + let server: String = String(libSessionVal: community.base_url) + let roomToken: String = String(libSessionVal: community.room) + + communities.append( + PrioritisedData( + data: OpenGroupUrlInfo( + threadId: OpenGroup.idFor(roomToken: roomToken, server: server), + server: server, + roomToken: roomToken, + publicKey: Data( + libSessionVal: community.pubkey, + count: OpenGroup.pubkeyByteLength + ).toHexString() + ), + priority: community.priority + ) + ) + } + else if user_groups_it_is_legacy_group(groupsIterator, &legacyGroup) { + let groupId: String = String(libSessionVal: legacyGroup.session_id) + let members: [String: Bool] = SessionUtil.memberInfo(in: &legacyGroup) + + legacyGroups.append( + LegacyGroupInfo( + id: groupId, + name: String(libSessionVal: legacyGroup.name), + lastKeyPair: ClosedGroupKeyPair( + threadId: groupId, + publicKey: Data( + libSessionVal: legacyGroup.enc_pubkey, + count: ClosedGroup.pubkeyByteLength + ), + secretKey: Data( + libSessionVal: legacyGroup.enc_seckey, + count: ClosedGroup.secretKeyByteLength + ), + receivedTimestamp: (TimeInterval(SnodeAPI.currentOffsetTimestampMs()) / 1000) + ), + disappearingConfig: DisappearingMessagesConfiguration + .defaultWith(groupId) + .with( + isEnabled: (legacyGroup.disappearing_timer > 0), + durationSeconds: TimeInterval(legacyGroup.disappearing_timer) + ), + groupMembers: members + .filter { _, isAdmin in !isAdmin } + .map { memberId, _ in + GroupMember( + groupId: groupId, + profileId: memberId, + role: .standard, + isHidden: false + ) + }, + groupAdmins: members + .filter { _, isAdmin in isAdmin } + .map { memberId, _ in + GroupMember( + groupId: groupId, + profileId: memberId, + role: .admin, + isHidden: false + ) + }, + priority: legacyGroup.priority, + joinedAt: legacyGroup.joined_at + ) + ) + } + else { + SNLog("Ignoring unknown conversation type when iterating through volatile conversation info update") + } + + user_groups_iterator_advance(groupsIterator) + } + user_groups_iterator_free(groupsIterator) // Need to free the iterator + + // Extract all community/legacyGroup/group thread priorities + let existingThreadInfo: [String: PriorityVisibilityInfo] = (try? SessionThread + .select(.id, .variant, .pinnedPriority, .shouldBeVisible) + .filter( + [ + SessionThread.Variant.community, + SessionThread.Variant.legacyGroup, + SessionThread.Variant.group + ].contains(SessionThread.Columns.variant) + ) + .asRequest(of: PriorityVisibilityInfo.self) + .fetchAll(db)) + .defaulting(to: []) + .reduce(into: [:]) { result, next in result[next.id] = next } + + // MARK: -- Handle Community Changes + + // Add any new communities (via the OpenGroupManager) + communities.forEach { community in + let successfullyAddedGroup: Bool = OpenGroupManager.shared + .add( + db, + roomToken: community.data.roomToken, + server: community.data.server, + publicKey: community.data.publicKey, + calledFromConfigHandling: true + ) + + if successfullyAddedGroup { + db.afterNextTransactionNested { _ in + OpenGroupManager.shared.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: community.data.roomToken, + server: community.data.server, + publicKey: community.data.publicKey, + calledFromConfigHandling: false + ) + .subscribe(on: OpenGroupAPI.workQueue) + .sinkUntilComplete() + } + } + + // Set the priority if it's changed (new communities will have already been inserted at + // this stage) + if existingThreadInfo[community.data.threadId]?.pinnedPriority != community.priority { + _ = try? SessionThread + .filter(id: community.data.threadId) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + SessionThread.Columns.pinnedPriority.set(to: community.priority) + ) + } + } + + // Remove any communities which are no longer in the config + let communityIdsToRemove: Set = Set(existingThreadInfo + .filter { $0.value.variant == .community } + .keys) + .subtracting(communities.map { $0.data.threadId }) + + if !communityIdsToRemove.isEmpty { + SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: Array(communityIdsToRemove)) + + try SessionThread + .deleteOrLeave( + db, + threadIds: Array(communityIdsToRemove), + threadVariant: .community, + groupLeaveType: .forced, + calledFromConfigHandling: true + ) + } + + // MARK: -- Handle Legacy Group Changes + + let existingLegacyGroupIds: Set = Set(existingThreadInfo + .filter { $0.value.variant == .legacyGroup } + .keys) + let existingLegacyGroups: [String: ClosedGroup] = (try? ClosedGroup + .fetchAll(db, ids: existingLegacyGroupIds)) + .defaulting(to: []) + .reduce(into: [:]) { result, next in result[next.id] = next } + let existingLegacyGroupMembers: [String: [GroupMember]] = (try? GroupMember + .filter(existingLegacyGroupIds.contains(GroupMember.Columns.groupId)) + .fetchAll(db)) + .defaulting(to: []) + .grouped(by: \.groupId) + + try legacyGroups.forEach { group in + guard + let name: String = group.name, + let lastKeyPair: ClosedGroupKeyPair = group.lastKeyPair, + let members: [GroupMember] = group.groupMembers, + let updatedAdmins: Set = group.groupAdmins?.asSet(), + let joinedAt: Int64 = group.joinedAt + else { return } + + if !existingLegacyGroupIds.contains(group.id) { + // Add a new group if it doesn't already exist + try MessageReceiver.handleNewClosedGroup( + db, + groupPublicKey: group.id, + name: name, + encryptionKeyPair: KeyPair( + publicKey: lastKeyPair.publicKey.bytes, + secretKey: lastKeyPair.secretKey.bytes + ), + members: members + .asSet() + .inserting(contentsOf: updatedAdmins) // Admins should also have 'standard' member entries + .map { $0.profileId }, + admins: updatedAdmins.map { $0.profileId }, + expirationTimer: UInt32(group.disappearingConfig?.durationSeconds ?? 0), + formationTimestampMs: UInt64((group.joinedAt.map { $0 * 1000 } ?? latestConfigSentTimestampMs)), + calledFromConfigHandling: true + ) + } + else { + // Otherwise update the existing group + let groupChanges: [ConfigColumnAssignment] = [ + (existingLegacyGroups[group.id]?.name == name ? nil : + ClosedGroup.Columns.name.set(to: name) + ), + (existingLegacyGroups[group.id]?.formationTimestamp == TimeInterval(joinedAt) ? nil : + ClosedGroup.Columns.formationTimestamp.set(to: TimeInterval(joinedAt)) + ) + ].compactMap { $0 } + + // Apply any group changes + if !groupChanges.isEmpty { + _ = try? ClosedGroup + .filter(id: group.id) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + groupChanges + ) + } + + // Add the lastKey if it doesn't already exist + let keyPairExists: Bool = ClosedGroupKeyPair + .filter(ClosedGroupKeyPair.Columns.threadKeyPairHash == lastKeyPair.threadKeyPairHash) + .isNotEmpty(db) + + if !keyPairExists { + try lastKeyPair.insert(db) + } + + // Update the disappearing messages timer + _ = try DisappearingMessagesConfiguration + .fetchOne(db, id: group.id) + .defaulting(to: DisappearingMessagesConfiguration.defaultWith(group.id)) + .with( + isEnabled: (group.disappearingConfig?.isEnabled == true), + durationSeconds: group.disappearingConfig?.durationSeconds + ) + .saved(db) + + // Update the members + let updatedMembers: Set = members + .appending( + contentsOf: updatedAdmins.map { admin in + GroupMember( + groupId: admin.groupId, + profileId: admin.profileId, + role: .standard, + isHidden: false + ) + } + ) + .asSet() + + if + let existingMembers: Set = existingLegacyGroupMembers[group.id]? + .filter({ $0.role == .standard || $0.role == .zombie }) + .asSet(), + existingMembers != updatedMembers + { + // Add in any new members and remove any removed members + try updatedMembers.forEach { try $0.save(db) } + try existingMembers + .filter { !updatedMembers.contains($0) } + .forEach { member in + try GroupMember + .filter( + GroupMember.Columns.groupId == group.id && + GroupMember.Columns.profileId == member.profileId && ( + GroupMember.Columns.role == GroupMember.Role.standard || + GroupMember.Columns.role == GroupMember.Role.zombie + ) + ) + .deleteAll(db) + } + } + + if + let existingAdmins: Set = existingLegacyGroupMembers[group.id]? + .filter({ $0.role == .admin }) + .asSet(), + existingAdmins != updatedAdmins + { + // Add in any new admins and remove any removed admins + try updatedAdmins.forEach { try $0.save(db) } + try existingAdmins + .filter { !updatedAdmins.contains($0) } + .forEach { member in + try GroupMember + .filter( + GroupMember.Columns.groupId == group.id && + GroupMember.Columns.profileId == member.profileId && + GroupMember.Columns.role == GroupMember.Role.admin + ) + .deleteAll(db) + } + } + } + + // Make any thread-specific changes if needed + if existingThreadInfo[group.id]?.pinnedPriority != group.priority { + _ = try? SessionThread + .filter(id: group.id) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + SessionThread.Columns.pinnedPriority.set(to: group.priority) + ) + } + } + + // Remove any legacy groups which are no longer in the config + let legacyGroupIdsToRemove: Set = existingLegacyGroupIds + .subtracting(legacyGroups.map { $0.id }) + + if !legacyGroupIdsToRemove.isEmpty { + SessionUtil.kickFromConversationUIIfNeeded(removedThreadIds: Array(legacyGroupIdsToRemove)) + + try SessionThread + .deleteOrLeave( + db, + threadIds: Array(legacyGroupIdsToRemove), + threadVariant: .legacyGroup, + groupLeaveType: .forced, + calledFromConfigHandling: true + ) + } + + // MARK: -- Handle Group Changes + + } + + fileprivate static func memberInfo(in legacyGroup: UnsafeMutablePointer) -> [String: Bool] { + let membersIt: OpaquePointer = ugroups_legacy_members_begin(legacyGroup) + var members: [String: Bool] = [:] + var maybeMemberSessionId: UnsafePointer? = nil + var memberAdmin: Bool = false + + while ugroups_legacy_members_next(membersIt, &maybeMemberSessionId, &memberAdmin) { + guard let memberSessionId: UnsafePointer = maybeMemberSessionId else { + continue + } + + members[String(cString: memberSessionId)] = memberAdmin + } + + return members + } + + // MARK: - Outgoing Changes + + static func upsert( + legacyGroups: [LegacyGroupInfo], + in conf: UnsafeMutablePointer? + ) throws { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + guard !legacyGroups.isEmpty else { return } + + try legacyGroups + .forEach { legacyGroup in + var cGroupId: [CChar] = legacyGroup.id.cArray.nullTerminated() + guard let userGroup: UnsafeMutablePointer = user_groups_get_or_construct_legacy_group(conf, &cGroupId) else { + /// It looks like there are some situations where this object might not get created correctly (and + /// will throw due to the implicit unwrapping) as a result we put it in a guard and throw instead + SNLog("Unable to upsert legacy group conversation to SessionUtil: \(SessionUtil.lastError(conf))") + throw SessionUtilError.getOrConstructFailedUnexpectedly + } + + // Assign all properties to match the updated group (if there is one) + if let updatedName: String = legacyGroup.name { + userGroup.pointee.name = updatedName.toLibSession() + + // Store the updated group (needs to happen before variables go out of scope) + user_groups_set_legacy_group(conf, userGroup) + } + + if let lastKeyPair: ClosedGroupKeyPair = legacyGroup.lastKeyPair { + userGroup.pointee.enc_pubkey = lastKeyPair.publicKey.toLibSession() + userGroup.pointee.enc_seckey = lastKeyPair.secretKey.toLibSession() + userGroup.pointee.have_enc_keys = true + + // Store the updated group (needs to happen before variables go out of scope) + user_groups_set_legacy_group(conf, userGroup) + } + + // Assign all properties to match the updated disappearing messages config (if there is one) + if let updatedConfig: DisappearingMessagesConfiguration = legacyGroup.disappearingConfig { + userGroup.pointee.disappearing_timer = (!updatedConfig.isEnabled ? 0 : + Int64(floor(updatedConfig.durationSeconds)) + ) + + user_groups_set_legacy_group(conf, userGroup) + } + + // Add/Remove the group members and admins + let existingMembers: [String: Bool] = { + guard legacyGroup.groupMembers != nil || legacyGroup.groupAdmins != nil else { return [:] } + + return SessionUtil.memberInfo(in: userGroup) + }() + + if let groupMembers: [GroupMember] = legacyGroup.groupMembers { + // Need to make sure we remove any admins before adding them here otherwise we will + // overwrite the admin permission to be a standard user permission + let memberIds: Set = groupMembers + .map { $0.profileId } + .asSet() + .subtracting(legacyGroup.groupAdmins.defaulting(to: []).map { $0.profileId }.asSet()) + let existingMemberIds: Set = Array(existingMembers + .filter { _, isAdmin in !isAdmin } + .keys) + .asSet() + let membersIdsToAdd: Set = memberIds.subtracting(existingMemberIds) + let membersIdsToRemove: Set = existingMemberIds.subtracting(memberIds) + + membersIdsToAdd.forEach { memberId in + var cProfileId: [CChar] = memberId.cArray.nullTerminated() + ugroups_legacy_member_add(userGroup, &cProfileId, false) + } + + membersIdsToRemove.forEach { memberId in + var cProfileId: [CChar] = memberId.cArray.nullTerminated() + ugroups_legacy_member_remove(userGroup, &cProfileId) + } + } + + if let groupAdmins: [GroupMember] = legacyGroup.groupAdmins { + let adminIds: Set = groupAdmins.map { $0.profileId }.asSet() + let existingAdminIds: Set = Array(existingMembers + .filter { _, isAdmin in isAdmin } + .keys) + .asSet() + let adminIdsToAdd: Set = adminIds.subtracting(existingAdminIds) + let adminIdsToRemove: Set = existingAdminIds.subtracting(adminIds) + + adminIdsToAdd.forEach { adminId in + var cProfileId: [CChar] = adminId.cArray.nullTerminated() + ugroups_legacy_member_add(userGroup, &cProfileId, true) + } + + adminIdsToRemove.forEach { adminId in + var cProfileId: [CChar] = adminId.cArray.nullTerminated() + ugroups_legacy_member_remove(userGroup, &cProfileId) + } + } + + if let joinedAt: Int64 = legacyGroup.joinedAt { + userGroup.pointee.joined_at = joinedAt + } + + // Store the updated group (can't be sure if we made any changes above) + userGroup.pointee.priority = (legacyGroup.priority ?? userGroup.pointee.priority) + + // Note: Need to free the legacy group pointer + user_groups_set_free_legacy_group(conf, userGroup) + } + } + + static func upsert( + communities: [CommunityInfo], + in conf: UnsafeMutablePointer? + ) throws { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + guard !communities.isEmpty else { return } + + try communities + .forEach { community in + var cBaseUrl: [CChar] = community.urlInfo.server.cArray.nullTerminated() + var cRoom: [CChar] = community.urlInfo.roomToken.cArray.nullTerminated() + var cPubkey: [UInt8] = Data(hex: community.urlInfo.publicKey).cArray + var userCommunity: ugroups_community_info = ugroups_community_info() + + guard user_groups_get_or_construct_community(conf, &userCommunity, &cBaseUrl, &cRoom, &cPubkey) else { + /// It looks like there are some situations where this object might not get created correctly (and + /// will throw due to the implicit unwrapping) as a result we put it in a guard and throw instead + SNLog("Unable to upsert community conversation to SessionUtil: \(SessionUtil.lastError(conf))") + throw SessionUtilError.getOrConstructFailedUnexpectedly + } + + userCommunity.priority = (community.priority ?? userCommunity.priority) + user_groups_set_community(conf, &userCommunity) + } + } +} + +// MARK: - External Outgoing Changes + +public extension SessionUtil { + + // MARK: -- Communities + + static func add( + _ db: Database, + server: String, + rootToken: String, + publicKey: String + ) throws { + try SessionUtil.performAndPushChange( + db, + for: .userGroups, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + try SessionUtil.upsert( + communities: [ + CommunityInfo( + urlInfo: OpenGroupUrlInfo( + threadId: OpenGroup.idFor(roomToken: rootToken, server: server), + server: server, + roomToken: rootToken, + publicKey: publicKey + ) + ) + ], + in: conf + ) + } + } + + static func remove(_ db: Database, server: String, roomToken: String) throws { + try SessionUtil.performAndPushChange( + db, + for: .userGroups, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + var cBaseUrl: [CChar] = server.cArray.nullTerminated() + var cRoom: [CChar] = roomToken.cArray.nullTerminated() + + // Don't care if the community doesn't exist + user_groups_erase_community(conf, &cBaseUrl, &cRoom) + } + + // Remove the volatile info as well + try SessionUtil.remove( + db, + volatileCommunityInfo: [ + OpenGroupUrlInfo( + threadId: OpenGroup.idFor(roomToken: roomToken, server: server), + server: server, + roomToken: roomToken, + publicKey: "" + ) + ] + ) + } + + // MARK: -- Legacy Group Changes + + static func add( + _ db: Database, + groupPublicKey: String, + name: String, + latestKeyPairPublicKey: Data, + latestKeyPairSecretKey: Data, + latestKeyPairReceivedTimestamp: TimeInterval, + disappearingConfig: DisappearingMessagesConfiguration, + members: Set, + admins: Set + ) throws { + try SessionUtil.performAndPushChange( + db, + for: .userGroups, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + var cGroupId: [CChar] = groupPublicKey.cArray.nullTerminated() + let userGroup: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cGroupId) + + // Need to make sure the group doesn't already exist (otherwise we will end up overriding the + // content which could revert newer changes since this can be triggered from other 'NEW' messages + // coming in from the legacy group swarm) + guard userGroup == nil else { + ugroups_legacy_group_free(userGroup) + return + } + + try SessionUtil.upsert( + legacyGroups: [ + LegacyGroupInfo( + id: groupPublicKey, + name: name, + lastKeyPair: ClosedGroupKeyPair( + threadId: groupPublicKey, + publicKey: latestKeyPairPublicKey, + secretKey: latestKeyPairSecretKey, + receivedTimestamp: latestKeyPairReceivedTimestamp + ), + disappearingConfig: disappearingConfig, + groupMembers: members + .map { memberId in + GroupMember( + groupId: groupPublicKey, + profileId: memberId, + role: .standard, + isHidden: false + ) + }, + groupAdmins: admins + .map { memberId in + GroupMember( + groupId: groupPublicKey, + profileId: memberId, + role: .admin, + isHidden: false + ) + } + ) + ], + in: conf + ) + } + } + + static func update( + _ db: Database, + groupPublicKey: String, + name: String? = nil, + latestKeyPair: ClosedGroupKeyPair? = nil, + disappearingConfig: DisappearingMessagesConfiguration? = nil, + members: Set? = nil, + admins: Set? = nil + ) throws { + try SessionUtil.performAndPushChange( + db, + for: .userGroups, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + try SessionUtil.upsert( + legacyGroups: [ + LegacyGroupInfo( + id: groupPublicKey, + name: name, + lastKeyPair: latestKeyPair, + disappearingConfig: disappearingConfig, + groupMembers: members? + .map { memberId in + GroupMember( + groupId: groupPublicKey, + profileId: memberId, + role: .standard, + isHidden: false + ) + }, + groupAdmins: admins? + .map { memberId in + GroupMember( + groupId: groupPublicKey, + profileId: memberId, + role: .admin, + isHidden: false + ) + } + ) + ], + in: conf + ) + } + } + + static func remove(_ db: Database, legacyGroupIds: [String]) throws { + guard !legacyGroupIds.isEmpty else { return } + + try SessionUtil.performAndPushChange( + db, + for: .userGroups, + publicKey: getUserHexEncodedPublicKey(db) + ) { conf in + legacyGroupIds.forEach { threadId in + var cGroupId: [CChar] = threadId.cArray.nullTerminated() + + // Don't care if the group doesn't exist + user_groups_erase_legacy_group(conf, &cGroupId) + } + } + + // Remove the volatile info as well + try SessionUtil.remove(db, volatileLegacyGroupIds: legacyGroupIds) + } + + // MARK: -- Group Changes + + static func remove(_ db: Database, groupIds: [String]) throws { + guard !groupIds.isEmpty else { return } + + } +} + +// MARK: - LegacyGroupInfo + +extension SessionUtil { + struct LegacyGroupInfo: Decodable, FetchableRecord, ColumnExpressible { + private static let threadIdKey: SQL = SQL(stringLiteral: CodingKeys.threadId.stringValue) + private static let nameKey: SQL = SQL(stringLiteral: CodingKeys.name.stringValue) + private static let lastKeyPairKey: SQL = SQL(stringLiteral: CodingKeys.lastKeyPair.stringValue) + private static let disappearingConfigKey: SQL = SQL(stringLiteral: CodingKeys.disappearingConfig.stringValue) + private static let priorityKey: SQL = SQL(stringLiteral: CodingKeys.priority.stringValue) + private static let joinedAtKey: SQL = SQL(stringLiteral: CodingKeys.joinedAt.stringValue) + + typealias Columns = CodingKeys + enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { + case threadId + case name + case lastKeyPair + case disappearingConfig + case groupMembers + case groupAdmins + case priority + case joinedAt = "formationTimestamp" + } + + var id: String { threadId } + + let threadId: String + let name: String? + let lastKeyPair: ClosedGroupKeyPair? + let disappearingConfig: DisappearingMessagesConfiguration? + let groupMembers: [GroupMember]? + let groupAdmins: [GroupMember]? + let priority: Int32? + let joinedAt: Int64? + + init( + id: String, + name: String? = nil, + lastKeyPair: ClosedGroupKeyPair? = nil, + disappearingConfig: DisappearingMessagesConfiguration? = nil, + groupMembers: [GroupMember]? = nil, + groupAdmins: [GroupMember]? = nil, + priority: Int32? = nil, + joinedAt: Int64? = nil + ) { + self.threadId = id + self.name = name + self.lastKeyPair = lastKeyPair + self.disappearingConfig = disappearingConfig + self.groupMembers = groupMembers + self.groupAdmins = groupAdmins + self.priority = priority + self.joinedAt = joinedAt + } + + static func fetchAll(_ db: Database) throws -> [LegacyGroupInfo] { + let closedGroup: TypedTableAlias = TypedTableAlias() + let thread: TypedTableAlias = TypedTableAlias() + let keyPair: TypedTableAlias = TypedTableAlias() + + let prefixLiteral: SQL = SQL(stringLiteral: "\(SessionId.Prefix.standard.rawValue)%") + let keyPairThreadIdColumnLiteral: SQL = SQL(stringLiteral: ClosedGroupKeyPair.Columns.threadId.name) + let receivedTimestampColumnLiteral: SQL = SQL(stringLiteral: ClosedGroupKeyPair.Columns.receivedTimestamp.name) + let threadIdColumnLiteral: SQL = SQL(stringLiteral: DisappearingMessagesConfiguration.Columns.threadId.name) + + /// **Note:** The `numColumnsBeforeTypes` value **MUST** match the number of fields before + /// the `LegacyGroupInfo.lastKeyPairKey` entry below otherwise the query will fail to + /// parse and might throw + /// + /// Explicitly set default values for the fields ignored for search results + let numColumnsBeforeTypes: Int = 4 + + let request: SQLRequest = """ + SELECT + \(closedGroup[.threadId]) AS \(LegacyGroupInfo.threadIdKey), + \(closedGroup[.name]) AS \(LegacyGroupInfo.nameKey), + \(closedGroup[.formationTimestamp]) AS \(LegacyGroupInfo.joinedAtKey), + \(thread[.pinnedPriority]) AS \(LegacyGroupInfo.priorityKey), + \(LegacyGroupInfo.lastKeyPairKey).*, + \(LegacyGroupInfo.disappearingConfigKey).* + + FROM \(ClosedGroup.self) + JOIN \(SessionThread.self) ON \(thread[.id]) = \(closedGroup[.threadId]) + LEFT JOIN ( + SELECT + \(keyPair[.threadId]), + \(keyPair[.publicKey]), + \(keyPair[.secretKey]), + MAX(\(keyPair[.receivedTimestamp])) AS \(receivedTimestampColumnLiteral), + \(keyPair[.threadKeyPairHash]) + FROM \(ClosedGroupKeyPair.self) + GROUP BY \(keyPair[.threadId]) + ) AS \(LegacyGroupInfo.lastKeyPairKey) ON \(LegacyGroupInfo.lastKeyPairKey).\(keyPairThreadIdColumnLiteral) = \(closedGroup[.threadId]) + LEFT JOIN \(DisappearingMessagesConfiguration.self) AS \(LegacyGroupInfo.disappearingConfigKey) ON \(LegacyGroupInfo.disappearingConfigKey).\(threadIdColumnLiteral) = \(closedGroup[.threadId]) + + WHERE \(SQL("\(closedGroup[.threadId]) LIKE '\(prefixLiteral)'")) + """ + + let legacyGroupInfoNoMembers: [LegacyGroupInfo] = try request + .adapted { db in + let adapters = try splittingRowAdapters(columnCounts: [ + numColumnsBeforeTypes, + ClosedGroupKeyPair.numberOfSelectedColumns(db), + DisappearingMessagesConfiguration.numberOfSelectedColumns(db) + ]) + + return ScopeAdapter([ + CodingKeys.lastKeyPair.stringValue: adapters[1], + CodingKeys.disappearingConfig.stringValue: adapters[2] + ]) + } + .fetchAll(db) + let legacyGroupIds: [String] = legacyGroupInfoNoMembers.map { $0.threadId } + let allLegacyGroupMembers: [String: [GroupMember]] = try GroupMember + .filter(legacyGroupIds.contains(GroupMember.Columns.groupId)) + .fetchAll(db) + .grouped(by: \.groupId) + + return legacyGroupInfoNoMembers + .map { nonMemberGroup in + LegacyGroupInfo( + id: nonMemberGroup.id, + name: nonMemberGroup.name, + lastKeyPair: nonMemberGroup.lastKeyPair, + disappearingConfig: nonMemberGroup.disappearingConfig, + groupMembers: allLegacyGroupMembers[nonMemberGroup.id]? + .filter { $0.role == .standard || $0.role == .zombie }, + groupAdmins: allLegacyGroupMembers[nonMemberGroup.id]? + .filter { $0.role == .admin }, + priority: nonMemberGroup.priority, + joinedAt: nonMemberGroup.joinedAt + ) + } + } + } + + struct CommunityInfo { + let urlInfo: OpenGroupUrlInfo + let priority: Int32? + + init( + urlInfo: OpenGroupUrlInfo, + priority: Int32? = nil + ) { + self.urlInfo = urlInfo + self.priority = priority + } + } + + fileprivate struct GroupThreadData { + let communities: [PrioritisedData] + let legacyGroups: [PrioritisedData] + let groups: [PrioritisedData] + } + + fileprivate struct PrioritisedData { + let data: T + let priority: Int32 + } +} diff --git a/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift new file mode 100644 index 000000000..2bbe3cc4d --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Config Handling/SessionUtil+UserProfile.swift @@ -0,0 +1,160 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtil +import SessionUtilitiesKit + +internal extension SessionUtil { + static let columnsRelatedToUserProfile: [Profile.Columns] = [ + Profile.Columns.name, + Profile.Columns.profilePictureUrl, + Profile.Columns.profileEncryptionKey + ] + + // MARK: - Incoming Changes + + static func handleUserProfileUpdate( + _ db: Database, + in conf: UnsafeMutablePointer?, + mergeNeedsDump: Bool, + latestConfigSentTimestampMs: Int64 + ) throws { + typealias ProfileData = (profileName: String, profilePictureUrl: String?, profilePictureKey: Data?) + + guard mergeNeedsDump else { return } + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + // A profile must have a name so if this is null then it's invalid and can be ignored + guard let profileNamePtr: UnsafePointer = user_profile_get_name(conf) else { return } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + let profileName: String = String(cString: profileNamePtr) + let profilePic: user_profile_pic = user_profile_get_pic(conf) + let profilePictureUrl: String? = String(libSessionVal: profilePic.url, nullIfEmpty: true) + + // Handle user profile changes + try ProfileManager.updateProfileIfNeeded( + db, + publicKey: userPublicKey, + name: profileName, + avatarUpdate: { + guard let profilePictureUrl: String = profilePictureUrl else { return .remove } + + return .updateTo( + url: profilePictureUrl, + key: Data( + libSessionVal: profilePic.key, + count: ProfileManager.avatarAES256KeyByteLength + ), + fileName: nil + ) + }(), + sentTimestamp: (TimeInterval(latestConfigSentTimestampMs) / 1000), + calledFromConfigHandling: true + ) + + // Update the 'Note to Self' visibility and priority + let threadInfo: PriorityVisibilityInfo? = try? SessionThread + .filter(id: userPublicKey) + .select(.id, .variant, .pinnedPriority, .shouldBeVisible) + .asRequest(of: PriorityVisibilityInfo.self) + .fetchOne(db) + let targetPriority: Int32 = user_profile_get_nts_priority(conf) + + // Create the 'Note to Self' thread if it doesn't exist + if let threadInfo: PriorityVisibilityInfo = threadInfo { + let threadChanges: [ConfigColumnAssignment] = [ + ((threadInfo.shouldBeVisible == SessionUtil.shouldBeVisible(priority: targetPriority)) ? nil : + SessionThread.Columns.shouldBeVisible.set(to: SessionUtil.shouldBeVisible(priority: targetPriority)) + ), + (threadInfo.pinnedPriority == targetPriority ? nil : + SessionThread.Columns.pinnedPriority.set(to: targetPriority) + ) + ].compactMap { $0 } + + if !threadChanges.isEmpty { + try SessionThread + .filter(id: userPublicKey) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + threadChanges + ) + } + } + else { + try SessionThread + .fetchOrCreate( + db, + id: userPublicKey, + variant: .contact, + shouldBeVisible: SessionUtil.shouldBeVisible(priority: targetPriority) + ) + + try SessionThread + .filter(id: userPublicKey) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + SessionThread.Columns.pinnedPriority.set(to: targetPriority) + ) + + // If the 'Note to Self' conversation is hidden then we should trigger the proper + // `deleteOrLeave` behaviour (for 'Note to Self' this will leave the conversation + // but remove the associated interactions) + if !SessionUtil.shouldBeVisible(priority: targetPriority) { + try SessionThread + .deleteOrLeave( + db, + threadId: userPublicKey, + threadVariant: .contact, + groupLeaveType: .forced, + calledFromConfigHandling: true + ) + } + } + + // Create a contact for the current user if needed (also force-approve the current user + // in case the account got into a weird state or restored directly from a migration) + let userContact: Contact = Contact.fetchOrCreate(db, id: userPublicKey) + + if !userContact.isTrusted || !userContact.isApproved || !userContact.didApproveMe { + try userContact.save(db) + try Contact + .filter(id: userPublicKey) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + Contact.Columns.isTrusted.set(to: true), // Always trust the current user + Contact.Columns.isApproved.set(to: true), + Contact.Columns.didApproveMe.set(to: true) + ) + } + } + + // MARK: - Outgoing Changes + + static func update( + profile: Profile, + in conf: UnsafeMutablePointer? + ) throws { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + // Update the name + var updatedName: [CChar] = profile.name.cArray.nullTerminated() + user_profile_set_name(conf, &updatedName) + + // Either assign the updated profile pic, or sent a blank profile pic (to remove the current one) + var profilePic: user_profile_pic = user_profile_pic() + profilePic.url = profile.profilePictureUrl.toLibSession() + profilePic.key = profile.profileEncryptionKey.toLibSession() + user_profile_set_pic(conf, profilePic) + } + + static func updateNoteToSelf( + priority: Int32, + in conf: UnsafeMutablePointer? + ) throws { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + user_profile_set_nts_priority(conf, priority) + } +} diff --git a/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift b/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift new file mode 100644 index 000000000..a8be039fe --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Database/QueryInterfaceRequest+Utilities.swift @@ -0,0 +1,125 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +// MARK: - ConfigColumnAssignment + +public struct ConfigColumnAssignment { + var column: ColumnExpression + var assignment: ColumnAssignment + + init( + column: ColumnExpression, + assignment: ColumnAssignment + ) { + self.column = column + self.assignment = assignment + } +} + +// MARK: - ColumnExpression + +extension ColumnExpression { + public func set(to value: (any SQLExpressible)?) -> ConfigColumnAssignment { + ConfigColumnAssignment(column: self, assignment: self.set(to: value)) + } +} + +// MARK: - QueryInterfaceRequest + +public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & TableRecord { + + // MARK: -- updateAll + + @discardableResult + func updateAll( + _ db: Database, + _ assignments: ConfigColumnAssignment... + ) throws -> Int { + return try updateAll(db, assignments) + } + + @discardableResult + func updateAll( + _ db: Database, + _ assignments: [ConfigColumnAssignment] + ) throws -> Int { + return try self.updateAll(db, assignments.map { $0.assignment }) + } + + @discardableResult + func updateAllAndConfig( + _ db: Database, + _ assignments: ConfigColumnAssignment... + ) throws -> Int { + return try updateAllAndConfig(db, assignments) + } + + @discardableResult + func updateAllAndConfig( + _ db: Database, + _ assignments: [ConfigColumnAssignment] + ) throws -> Int { + let targetAssignments: [ColumnAssignment] = assignments.map { $0.assignment } + + // Before we do anything custom make sure the changes actually do need to be synced + guard SessionUtil.assignmentsRequireConfigUpdate(assignments) else { + return try self.updateAll(db, targetAssignments) + } + + return try self.updateAndFetchAllAndUpdateConfig(db, assignments).count + } + + // MARK: -- updateAndFetchAll + + @discardableResult + func updateAndFetchAllAndUpdateConfig( + _ db: Database, + _ assignments: ConfigColumnAssignment... + ) throws -> [RowDecoder] { + return try updateAndFetchAllAndUpdateConfig(db, assignments) + } + + @discardableResult + func updateAndFetchAllAndUpdateConfig( + _ db: Database, + _ assignments: [ConfigColumnAssignment] + ) throws -> [RowDecoder] { + // First perform the actual updates + let updatedData: [RowDecoder] = try self.updateAndFetchAll(db, assignments.map { $0.assignment }) + + // Then check if any of the changes could affect the config + guard + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + SessionUtil.userConfigsEnabled(db, ignoreRequirementsForRunningMigrations: true), + SessionUtil.assignmentsRequireConfigUpdate(assignments) + else { return updatedData } + + defer { + // If we changed a column that requires a config update then we may as well automatically + // enqueue a new config sync job once the transaction completes (but only enqueue it once + // per transaction - doing it more than once is pointless) + let userPublicKey: String = getUserHexEncodedPublicKey(db) + + db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(userPublicKey)) { db in + ConfigurationSyncJob.enqueue(db, publicKey: userPublicKey) + } + } + + // Update the config dump state where needed + switch self { + case is QueryInterfaceRequest: + return try SessionUtil.updatingContacts(db, updatedData) + + case is QueryInterfaceRequest: + return try SessionUtil.updatingProfiles(db, updatedData) + + case is QueryInterfaceRequest: + return try SessionUtil.updatingThreads(db, updatedData) + + default: return updatedData + } + } +} diff --git a/SessionMessagingKit/SessionUtil/SessionUtil.swift b/SessionMessagingKit/SessionUtil/SessionUtil.swift new file mode 100644 index 000000000..d933238a5 --- /dev/null +++ b/SessionMessagingKit/SessionUtil/SessionUtil.swift @@ -0,0 +1,591 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionSnodeKit +import SessionUtil +import SessionUtilitiesKit + +// MARK: - Features + +public extension Features { + static func useSharedUtilForUserConfig(_ db: Database? = nil) -> Bool { + guard Date().timeIntervalSince1970 < 1690761600 else { return true } + guard !SessionUtil.hasCheckedMigrationsCompleted.wrappedValue else { + return SessionUtil.userConfigsEnabledIgnoringFeatureFlag + } + + if let db: Database = db { + return SessionUtil.refreshingUserConfigsEnabled(db) + } + + return Storage.shared + .read { db in SessionUtil.refreshingUserConfigsEnabled(db) } + .defaulting(to: false) + } +} + +// MARK: - SessionUtil + +public enum SessionUtil { + public struct ConfResult { + let needsPush: Bool + let needsDump: Bool + } + + public struct IncomingConfResult { + let needsPush: Bool + let needsDump: Bool + let messageHashes: [String] + let latestSentTimestamp: TimeInterval + + var result: ConfResult { ConfResult(needsPush: needsPush, needsDump: needsDump) } + } + + public struct OutgoingConfResult { + let message: SharedConfigMessage + let namespace: SnodeAPI.Namespace + let obsoleteHashes: [String] + } + + // MARK: - Configs + + fileprivate static var configStore: Atomic<[ConfigKey: Atomic?>]> = Atomic([:]) + + public static func config(for variant: ConfigDump.Variant, publicKey: String) -> Atomic?> { + let key: ConfigKey = ConfigKey(variant: variant, publicKey: publicKey) + + return ( + SessionUtil.configStore.wrappedValue[key] ?? + Atomic(nil) + ) + } + + // MARK: - Variables + + internal static func syncDedupeId(_ publicKey: String) -> String { + return "EnqueueConfigurationSyncJob-\(publicKey)" + } + + /// Returns `true` if there is a config which needs to be pushed, but returns `false` if the configs are all up to date or haven't been + /// loaded yet (eg. fresh install) + public static var needsSync: Bool { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled else { return false } + + return configStore + .wrappedValue + .contains { _, atomicConf in + guard atomicConf.wrappedValue != nil else { return false } + + return config_needs_push(atomicConf.wrappedValue) + } + } + + public static var libSessionVersion: String { String(cString: LIBSESSION_UTIL_VERSION_STR) } + + fileprivate static let hasCheckedMigrationsCompleted: Atomic = Atomic(false) + private static let requiredMigrationsCompleted: Atomic = Atomic(false) + private static let requiredMigrationIdentifiers: Set = [ + TargetMigrations.Identifier.messagingKit.key(with: _013_SessionUtilChanges.self), + TargetMigrations.Identifier.messagingKit.key(with: _014_GenerateInitialUserConfigDumps.self) + ] + + public static var userConfigsEnabled: Bool { + return userConfigsEnabled(nil) + } + + public static func userConfigsEnabled(_ db: Database?) -> Bool { + Features.useSharedUtilForUserConfig(db) && + SessionUtil.userConfigsEnabledIgnoringFeatureFlag + } + + public static var userConfigsEnabledIgnoringFeatureFlag: Bool { + SessionUtil.requiredMigrationsCompleted.wrappedValue + } + + internal static func userConfigsEnabled( + _ db: Database, + ignoreRequirementsForRunningMigrations: Bool + ) -> Bool { + // First check if we are enabled regardless of what we want to ignore + guard + Features.useSharedUtilForUserConfig(db), + !SessionUtil.requiredMigrationsCompleted.wrappedValue, + !SessionUtil.refreshingUserConfigsEnabled(db), + ignoreRequirementsForRunningMigrations, + let currentlyRunningMigration: (identifier: TargetMigrations.Identifier, migration: Migration.Type) = Storage.shared.currentlyRunningMigration + else { return true } + + let nonIgnoredMigrationIdentifiers: Set = SessionUtil.requiredMigrationIdentifiers + .removing(currentlyRunningMigration.identifier.key(with: currentlyRunningMigration.migration)) + + return Storage.appliedMigrationIdentifiers(db) + .isSuperset(of: nonIgnoredMigrationIdentifiers) + } + + @discardableResult public static func refreshingUserConfigsEnabled(_ db: Database) -> Bool { + let result: Bool = Storage.appliedMigrationIdentifiers(db) + .isSuperset(of: SessionUtil.requiredMigrationIdentifiers) + + requiredMigrationsCompleted.mutate { $0 = result } + hasCheckedMigrationsCompleted.mutate { $0 = true } + + return result + } + + internal static func lastError(_ conf: UnsafeMutablePointer?) -> String { + return (conf?.pointee.last_error.map { String(cString: $0) } ?? "Unknown") + } + + // MARK: - Loading + + public static func clearMemoryState() { + // Ensure we have a loaded state before we continue + guard !SessionUtil.configStore.wrappedValue.isEmpty else { return } + + SessionUtil.configStore.mutate { confStore in + confStore.removeAll() + } + } + + public static func loadState( + _ db: Database? = nil, + userPublicKey: String, + ed25519SecretKey: [UInt8]? + ) { + // Ensure we have the ed25519 key and that we haven't already loaded the state before + // we continue + guard + let secretKey: [UInt8] = ed25519SecretKey, + SessionUtil.configStore.wrappedValue.isEmpty + else { return } + + // If we weren't given a database instance then get one + guard let db: Database = db else { + Storage.shared.read { db in + SessionUtil.loadState(db, userPublicKey: userPublicKey, ed25519SecretKey: secretKey) + } + return + } + + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled(db, ignoreRequirementsForRunningMigrations: true) else { return } + + // Retrieve the existing dumps from the database + let existingDumps: Set = ((try? ConfigDump.fetchSet(db)) ?? []) + let existingDumpVariants: Set = existingDumps + .map { $0.variant } + .asSet() + let missingRequiredVariants: Set = ConfigDump.Variant.userVariants + .asSet() + .subtracting(existingDumpVariants) + + // Create the 'config_object' records for each dump + SessionUtil.configStore.mutate { confStore in + existingDumps.forEach { dump in + confStore[ConfigKey(variant: dump.variant, publicKey: dump.publicKey)] = Atomic( + try? SessionUtil.loadState( + for: dump.variant, + secretKey: secretKey, + cachedData: dump.data + ) + ) + } + + missingRequiredVariants.forEach { variant in + confStore[ConfigKey(variant: variant, publicKey: userPublicKey)] = Atomic( + try? SessionUtil.loadState( + for: variant, + secretKey: secretKey, + cachedData: nil + ) + ) + } + } + } + + private static func loadState( + for variant: ConfigDump.Variant, + secretKey ed25519SecretKey: [UInt8], + cachedData: Data? + ) throws -> UnsafeMutablePointer? { + // Setup initial variables (including getting the memory address for any cached data) + var conf: UnsafeMutablePointer? = nil + let error: UnsafeMutablePointer? = nil + let cachedDump: (data: UnsafePointer, length: Int)? = cachedData?.withUnsafeBytes { unsafeBytes in + return unsafeBytes.baseAddress.map { + ( + $0.assumingMemoryBound(to: UInt8.self), + unsafeBytes.count + ) + } + } + + // No need to deallocate the `cachedDump.data` as it'll automatically be cleaned up by + // the `cachedDump` lifecycle, but need to deallocate the `error` if it gets set + defer { + error?.deallocate() + } + + // Try to create the object + var secretKey: [UInt8] = ed25519SecretKey + let result: Int32 = { + switch variant { + case .userProfile: + return user_profile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) + + case .contacts: + return contacts_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) + + case .convoInfoVolatile: + return convo_info_volatile_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) + + case .userGroups: + return user_groups_init(&conf, &secretKey, cachedDump?.data, (cachedDump?.length ?? 0), error) + } + }() + + guard result == 0 else { + let errorString: String = (error.map { String(cString: $0) } ?? "unknown error") + SNLog("[SessionUtil Error] Unable to create \(variant.rawValue) config object: \(errorString)") + throw SessionUtilError.unableToCreateConfigObject + } + + return conf + } + + internal static func createDump( + conf: UnsafeMutablePointer?, + for variant: ConfigDump.Variant, + publicKey: String, + timestampMs: Int64 + ) throws -> ConfigDump? { + guard conf != nil else { throw SessionUtilError.nilConfigObject } + + // If it doesn't need a dump then do nothing + guard config_needs_dump(conf) else { return nil } + + var dumpResult: UnsafeMutablePointer? = nil + var dumpResultLen: Int = 0 + try CExceptionHelper.performSafely { + config_dump(conf, &dumpResult, &dumpResultLen) + } + + guard let dumpResult: UnsafeMutablePointer = dumpResult else { return nil } + + let dumpData: Data = Data(bytes: dumpResult, count: dumpResultLen) + dumpResult.deallocate() + + return ConfigDump( + variant: variant, + publicKey: publicKey, + data: dumpData, + timestampMs: timestampMs + ) + } + + // MARK: - Pushes + + public static func pendingChanges( + _ db: Database, + publicKey: String + ) throws -> [OutgoingConfResult] { + guard Identity.userExists(db) else { throw SessionUtilError.userDoesNotExist } + + let userPublicKey: String = getUserHexEncodedPublicKey(db) + var existingDumpVariants: Set = try ConfigDump + .select(.variant) + .filter(ConfigDump.Columns.publicKey == publicKey) + .asRequest(of: ConfigDump.Variant.self) + .fetchSet(db) + + // Ensure we always check the required user config types for changes even if there is no dump + // data yet (to deal with first launch cases) + if publicKey == userPublicKey { + ConfigDump.Variant.userVariants.forEach { existingDumpVariants.insert($0) } + } + + // Ensure we always check the required user config types for changes even if there is no dump + // data yet (to deal with first launch cases) + return try existingDumpVariants + .compactMap { variant -> OutgoingConfResult? in + try SessionUtil + .config(for: variant, publicKey: publicKey) + .wrappedValue + .map { conf in + // Check if the config needs to be pushed + guard config_needs_push(conf) else { return nil } + + var cPushData: UnsafeMutablePointer! + let configCountInfo: String = { + var result: String = "Invalid" + + try? CExceptionHelper.performSafely { + switch variant { + case .userProfile: result = "1 profile" + case .contacts: result = "\(contacts_size(conf)) contacts" + case .userGroups: result = "\(user_groups_size(conf)) group conversations" + case .convoInfoVolatile: result = "\(convo_info_volatile_size(conf)) volatile conversations" + } + } + + return result + }() + + do { + try CExceptionHelper.performSafely { + cPushData = config_push(conf) + } + } + catch { + SNLog("[libSession] Failed to generate push data for \(variant) config data, size: \(configCountInfo), error: \(error)") + throw error + } + + let pushData: Data = Data( + bytes: cPushData.pointee.config, + count: cPushData.pointee.config_len + ) + let obsoleteHashes: [String] = [String]( + pointer: cPushData.pointee.obsolete, + count: cPushData.pointee.obsolete_len, + defaultValue: [] + ) + let seqNo: Int64 = cPushData.pointee.seqno + cPushData.deallocate() + + return OutgoingConfResult( + message: SharedConfigMessage( + kind: variant.configMessageKind, + seqNo: seqNo, + data: pushData + ), + namespace: variant.namespace, + obsoleteHashes: obsoleteHashes + ) + } + } + } + + public static func markingAsPushed( + message: SharedConfigMessage, + serverHash: String, + publicKey: String + ) -> ConfigDump? { + return SessionUtil + .config(for: message.kind.configDumpVariant, publicKey: publicKey) + .mutate { conf in + guard conf != nil else { return nil } + + // Mark the config as pushed + var cHash: [CChar] = serverHash.cArray.nullTerminated() + config_confirm_pushed(conf, message.seqNo, &cHash) + + // Update the result to indicate whether the config needs to be dumped + guard config_needs_dump(conf) else { return nil } + + return try? SessionUtil.createDump( + conf: conf, + for: message.kind.configDumpVariant, + publicKey: publicKey, + timestampMs: (message.sentTimestamp.map { Int64($0) } ?? 0) + ) + } + } + + public static func configHashes(for publicKey: String) -> [String] { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled else { return [] } + + return Storage.shared + .read { db -> Set in + guard Identity.userExists(db) else { return [] } + + return try ConfigDump + .select(.variant) + .filter(ConfigDump.Columns.publicKey == publicKey) + .asRequest(of: ConfigDump.Variant.self) + .fetchSet(db) + } + .defaulting(to: []) + .map { variant -> [String] in + /// Extract all existing hashes for any dumps associated with the given `publicKey` + guard + let conf = SessionUtil + .config(for: variant, publicKey: publicKey) + .wrappedValue, + let hashList: UnsafeMutablePointer = config_current_hashes(conf) + else { return [] } + + let result: [String] = [String]( + pointer: hashList.pointee.value, + count: hashList.pointee.len, + defaultValue: [] + ) + hashList.deallocate() + + return result + } + .reduce([], +) + } + + // MARK: - Receiving + + public static func handleConfigMessages( + _ db: Database, + messages: [SharedConfigMessage], + publicKey: String + ) throws { + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + guard SessionUtil.userConfigsEnabled(db) else { return } + guard !messages.isEmpty else { return } + guard !publicKey.isEmpty else { throw MessageReceiverError.noThread } + + let groupedMessages: [ConfigDump.Variant: [SharedConfigMessage]] = messages + .grouped(by: \.kind.configDumpVariant) + + let needsPush: Bool = try groupedMessages + .sorted { lhs, rhs in lhs.key.processingOrder < rhs.key.processingOrder } + .reduce(false) { prevNeedsPush, next -> Bool in + let latestConfigSentTimestampMs: Int64 = Int64(next.value.compactMap { $0.sentTimestamp }.max() ?? 0) + let needsPush: Bool = try SessionUtil + .config(for: next.key, publicKey: publicKey) + .mutate { conf in + // Merge the messages + var mergeHashes: [UnsafePointer?] = next.value + .map { message in (message.serverHash ?? "").cArray.nullTerminated() } + .unsafeCopy() + var mergeData: [UnsafePointer?] = next.value + .map { message -> [UInt8] in message.data.bytes } + .unsafeCopy() + var mergeSize: [Int] = next.value.map { $0.data.count } + config_merge(conf, &mergeHashes, &mergeData, &mergeSize, next.value.count) + mergeHashes.forEach { $0?.deallocate() } + mergeData.forEach { $0?.deallocate() } + + // Apply the updated states to the database + do { + switch next.key { + case .userProfile: + try SessionUtil.handleUserProfileUpdate( + db, + in: conf, + mergeNeedsDump: config_needs_dump(conf), + latestConfigSentTimestampMs: latestConfigSentTimestampMs + ) + + case .contacts: + try SessionUtil.handleContactsUpdate( + db, + in: conf, + mergeNeedsDump: config_needs_dump(conf), + latestConfigSentTimestampMs: latestConfigSentTimestampMs + ) + + case .convoInfoVolatile: + try SessionUtil.handleConvoInfoVolatileUpdate( + db, + in: conf, + mergeNeedsDump: config_needs_dump(conf) + ) + + case .userGroups: + try SessionUtil.handleGroupsUpdate( + db, + in: conf, + mergeNeedsDump: config_needs_dump(conf), + latestConfigSentTimestampMs: latestConfigSentTimestampMs + ) + } + } + catch { + SNLog("[libSession] Failed to process merge of \(next.key) config data") + throw error + } + + // Need to check if the config needs to be dumped (this might have changed + // after handling the merge changes) + guard config_needs_dump(conf) else { + try ConfigDump + .filter( + ConfigDump.Columns.variant == next.key && + ConfigDump.Columns.publicKey == publicKey + ) + .updateAll( + db, + ConfigDump.Columns.timestampMs.set(to: latestConfigSentTimestampMs) + ) + + return config_needs_push(conf) + } + + try SessionUtil.createDump( + conf: conf, + for: next.key, + publicKey: publicKey, + timestampMs: latestConfigSentTimestampMs + )?.save(db) + + return config_needs_push(conf) + } + + // Update the 'needsPush' state as needed + return (prevNeedsPush || needsPush) + } + + // Now that the local state has been updated, schedule a config sync if needed (this will + // push any pending updates and properly update the state) + guard needsPush else { return } + + db.afterNextTransactionNestedOnce(dedupeId: SessionUtil.syncDedupeId(publicKey)) { db in + ConfigurationSyncJob.enqueue(db, publicKey: publicKey) + } + } +} + +// MARK: - Internal Convenience + +fileprivate extension SessionUtil { + struct ConfigKey: Hashable { + let variant: ConfigDump.Variant + let publicKey: String + } +} + +// MARK: - Convenience + +public extension SessionUtil { + static func parseCommunity(url: String) -> (room: String, server: String, publicKey: String)? { + var cFullUrl: [CChar] = url.cArray.nullTerminated() + var cBaseUrl: [CChar] = [CChar](repeating: 0, count: COMMUNITY_BASE_URL_MAX_LENGTH) + var cRoom: [CChar] = [CChar](repeating: 0, count: COMMUNITY_ROOM_MAX_LENGTH) + var cPubkey: [UInt8] = [UInt8](repeating: 0, count: OpenGroup.pubkeyByteLength) + + guard + community_parse_full_url(&cFullUrl, &cBaseUrl, &cRoom, &cPubkey) && + !String(cString: cRoom).isEmpty && + !String(cString: cBaseUrl).isEmpty && + cPubkey.contains(where: { $0 != 0 }) + else { return nil } + + // Note: Need to store them in variables instead of returning directly to ensure they + // don't get freed from memory early (was seeing this happen intermittently during + // unit tests...) + let room: String = String(cString: cRoom) + let baseUrl: String = String(cString: cBaseUrl) + let pubkeyHex: String = Data(cPubkey).toHexString() + + return (room, baseUrl, pubkeyHex) + } + + static func communityUrlFor(server: String, roomToken: String, publicKey: String) -> String { + var cBaseUrl: [CChar] = server.cArray.nullTerminated() + var cRoom: [CChar] = roomToken.cArray.nullTerminated() + var cPubkey: [UInt8] = Data(hex: publicKey).cArray + var cFullUrl: [CChar] = [CChar](repeating: 0, count: COMMUNITY_FULL_URL_MAX_LENGTH) + community_make_full_url(&cBaseUrl, &cRoom, &cPubkey, &cFullUrl) + + return String(cString: cFullUrl) + } +} diff --git a/SessionMessagingKit/SessionUtil/SessionUtilError.swift b/SessionMessagingKit/SessionUtil/SessionUtilError.swift new file mode 100644 index 000000000..42da99da5 --- /dev/null +++ b/SessionMessagingKit/SessionUtil/SessionUtilError.swift @@ -0,0 +1,11 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum SessionUtilError: Error { + case unableToCreateConfigObject + case nilConfigObject + case userDoesNotExist + case getOrConstructFailedUnexpectedly + case processingLoopLimitReached +} diff --git a/SessionMessagingKit/SessionUtil/Utilities/TypeConversion+Utilities.swift b/SessionMessagingKit/SessionUtil/Utilities/TypeConversion+Utilities.swift new file mode 100644 index 000000000..edee7ba7a --- /dev/null +++ b/SessionMessagingKit/SessionUtil/Utilities/TypeConversion+Utilities.swift @@ -0,0 +1,177 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +// MARK: - String + +public extension String { + var cArray: [CChar] { [UInt8](self.utf8).map { CChar(bitPattern: $0) } } + + /// Initialize with an optional pointer and a specific length + init?(pointer: UnsafeRawPointer?, length: Int, encoding: String.Encoding = .utf8) { + guard + let pointer: UnsafeRawPointer = pointer, + let result: String = String(data: Data(bytes: pointer, count: length), encoding: encoding) + else { return nil } + + self = result + } + + init( + libSessionVal: T, + fixedLength: Int? = .none + ) { + guard let fixedLength: Int = fixedLength else { + // Note: The `String(cString:)` function requires that the value is null-terminated + // so add a null-termination character if needed + self = String( + cString: withUnsafeBytes(of: libSessionVal) { [UInt8]($0) } + .nullTerminated() + ) + return + } + + guard + let fixedLengthData: Data = Data( + libSessionVal: libSessionVal, + count: fixedLength, + nullIfEmpty: true + ), + let result: String = String(data: fixedLengthData, encoding: .utf8) + else { + self = "" + return + } + + self = result + } + + init?( + libSessionVal: T, + fixedLength: Int? = .none, + nullIfEmpty: Bool + ) { + let result = String(libSessionVal: libSessionVal, fixedLength: fixedLength) + + guard !nullIfEmpty || !result.isEmpty else { return nil } + + self = result + } + + func toLibSession() -> T { + let targetSize: Int = MemoryLayout.stride + var dataMatchingDestinationSize: [CChar] = [CChar](repeating: 0, count: targetSize) + dataMatchingDestinationSize.replaceSubrange( + 0.. { + func toLibSession() -> T { + switch self { + case .some(let value): return value.toLibSession() + case .none: return "".toLibSession() + } + } +} + +// MARK: - Data + +public extension Data { + var cArray: [UInt8] { [UInt8](self) } + + init(libSessionVal: T, count: Int) { + let result: Data = Swift.withUnsafePointer(to: libSessionVal) { + Data(bytes: $0, count: count) + } + + self = result + } + + init?(libSessionVal: T, count: Int, nullIfEmpty: Bool) { + let result: Data = Data(libSessionVal: libSessionVal, count: count) + + // If all of the values are 0 then return the data as null + guard !nullIfEmpty || result.contains(where: { $0 != 0 }) else { return nil } + + self = result + } + + func toLibSession() -> T { + let targetSize: Int = MemoryLayout.stride + var dataMatchingDestinationSize: Data = Data(count: targetSize) + dataMatchingDestinationSize.replaceSubrange( + 0.. { + func toLibSession() -> T { + switch self { + case .some(let value): return value.toLibSession() + case .none: return Data().toLibSession() + } + } +} + +// MARK: - Array + +public extension Array where Element == String { + init?( + pointer: UnsafeMutablePointer?>?, + count: Int? + ) { + guard + let pointee: UnsafeMutablePointer = pointer?.pointee, + let count: Int = count + else { return nil } + + self = (0..?>?, + count: Int?, + defaultValue: [String] + ) { + self = ([String](pointer: pointer, count: count) ?? defaultValue) + } +} + +public extension Array where Element == CChar { + func nullTerminated() -> [Element] { + guard self.last != CChar(0) else { return self } + + return self.appending(CChar(0)) + } +} + +public extension Array where Element == UInt8 { + func nullTerminated() -> [Element] { + guard self.last != UInt8(0) else { return self } + + return self.appending(UInt8(0)) + } +} diff --git a/SessionMessagingKit/Shared Models/MentionInfo.swift b/SessionMessagingKit/Shared Models/MentionInfo.swift index f20346518..984ddf63d 100644 --- a/SessionMessagingKit/Shared Models/MentionInfo.swift +++ b/SessionMessagingKit/Shared Models/MentionInfo.swift @@ -21,7 +21,7 @@ public extension MentionInfo { userPublicKey: String, threadId: String, threadVariant: SessionThread.Variant, - targetPrefix: SessionId.Prefix, + targetPrefixes: [SessionId.Prefix], pattern: FTS5Pattern? ) -> AdaptedFetchRequest>? { guard threadVariant != .contact || userPublicKey != threadId else { return nil } @@ -31,14 +31,16 @@ public extension MentionInfo { let openGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() - let prefixLiteral: SQL = SQL(stringLiteral: "\(targetPrefix.rawValue)%") + let prefixesLiteral: SQLExpression = targetPrefixes + .map { SQL("\(profile[.id]) LIKE '\(SQL(stringLiteral: "\($0.rawValue)%"))'") } + .joined(operator: .or) let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) /// The query needs to differ depending on the thread variant because the behaviour should be different: /// /// **Contact:** We should show the profile directly (filtered out if the pattern doesn't match) - /// **Closed Group:** We should show all profiles within the group, filtered by the pattern - /// **Open Group:** We should show only the 20 most recent profiles which match the pattern + /// **Group:** We should show all profiles within the group, filtered by the pattern + /// **Community:** We should show only the 20 most recent profiles which match the pattern let request: SQLRequest = { let hasValidPattern: Bool = (pattern != nil && pattern?.rawPattern != "\"\"*") let targetJoin: SQL = { @@ -49,8 +51,8 @@ public extension MentionInfo { JOIN \(Profile.self) ON ( \(Profile.self).rowid = \(profileFullTextSearch).rowid AND \(SQL("\(profile[.id]) != \(userPublicKey)")) AND ( - \(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR - \(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'")) + \(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR + \(prefixesLiteral) ) ) """ @@ -60,8 +62,8 @@ public extension MentionInfo { return """ WHERE ( \(SQL("\(profile[.id]) != \(userPublicKey)")) AND ( - \(SQL("\(threadVariant) != \(SessionThread.Variant.openGroup)")) OR - \(SQL("\(profile[.id]) LIKE '\(prefixLiteral)'")) + \(SQL("\(threadVariant) != \(SessionThread.Variant.community)")) OR + \(prefixesLiteral) ) ) """ @@ -83,7 +85,7 @@ public extension MentionInfo { \(targetWhere) AND \(SQL("\(profile[.id]) = \(threadId)")) """) - case .closedGroup: + case .legacyGroup, .group: return SQLRequest(""" SELECT \(Profile.self).*, @@ -100,7 +102,7 @@ public extension MentionInfo { ORDER BY IFNULL(\(profile[.nickname]), \(profile[.name])) ASC """) - case .openGroup: + case .community: return SQLRequest(""" SELECT \(Profile.self).*, diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index 18aa47248..735d971a1 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -34,6 +34,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, 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 canHaveProfileKey: SQL = SQL(stringLiteral: CodingKeys.canHaveProfile.stringValue) public static let shouldShowProfileKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowProfile.stringValue) public static let shouldShowDateHeaderKey: SQL = SQL(stringLiteral: CodingKeys.shouldShowDateHeader.stringValue) public static let positionInClusterKey: SQL = SQL(stringLiteral: CodingKeys.positionInCluster.stringValue) @@ -54,6 +55,16 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case genericAttachment case typingIndicator case dateHeader + case unreadMarker + + /// A number of the `CellType` entries are dynamically added to the dataset after processing, this flag indicates + /// whether the given type is one of them + public var isPostProcessed: Bool { + switch self { + case .typingIndicator, .dateHeader, .unreadMarker: return true + default: return false + } + } } public var differenceIdentifier: Int64 { id } @@ -72,6 +83,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let rowId: Int64 public let id: Int64 + public let openGroupServerMessageId: Int64? public let variant: Interaction.Variant public let timestampMs: Int64 public let receivedAtTimestampMs: Int64 @@ -115,6 +127,9 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, /// **Note:** This will only be populated for incoming messages public let senderName: String? + /// A flag indicating whether the profile view can be displayed + public let canHaveProfile: Bool + /// A flag indicating whether the profile view should be displayed public let shouldShowProfile: Bool @@ -147,14 +162,17 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let isLastOutgoing: Bool - /// This is the users blinded key (will only be set for messages within open groups) - public let currentUserBlindedPublicKey: String? + /// This is the users blinded15 key (will only be set for messages within open groups) + public let currentUserBlinded15PublicKey: String? + + /// This is the users blinded25 key (will only be set for messages within open groups) + public let currentUserBlinded25PublicKey: String? // MARK: - Mutation public func with( - attachments: Updatable<[Attachment]> = .existing, - reactionInfo: Updatable<[ReactionInfo]> = .existing + attachments: [Attachment]? = nil, + reactionInfo: [ReactionInfo]? = nil ) -> MessageViewModel { return MessageViewModel( threadId: self.threadId, @@ -166,6 +184,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, threadContactNameInternal: self.threadContactNameInternal, rowId: self.rowId, id: self.id, + openGroupServerMessageId: self.openGroupServerMessageId, variant: self.variant, timestampMs: self.timestampMs, receivedAtTimestampMs: self.receivedAtTimestampMs, @@ -191,6 +210,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, cellType: self.cellType, authorName: self.authorName, senderName: self.senderName, + canHaveProfile: self.canHaveProfile, shouldShowProfile: self.shouldShowProfile, shouldShowDateHeader: self.shouldShowDateHeader, containsOnlyEmoji: self.containsOnlyEmoji, @@ -200,7 +220,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isOnlyMessageInCluster: self.isOnlyMessageInCluster, isLast: self.isLast, isLastOutgoing: self.isLastOutgoing, - currentUserBlindedPublicKey: self.currentUserBlindedPublicKey + currentUserBlinded15PublicKey: self.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: self.currentUserBlinded25PublicKey ) } @@ -209,7 +230,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, nextModel: MessageViewModel?, isLast: Bool, isLastOutgoing: Bool, - currentUserBlindedPublicKey: String? + currentUserBlinded15PublicKey: String?, + currentUserBlinded25PublicKey: String? ) -> MessageViewModel { let cellType: CellType = { guard self.isTypingIndicator != true else { return .typingIndicator } @@ -313,6 +335,11 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case (false, true): return (.bottom, isOnlyMessageInCluster) } }() + let isGroupThread: Bool = ( + self.threadVariant == .community || + self.threadVariant == .legacyGroup || + self.threadVariant == .group + ) return ViewModel( threadId: self.threadId, @@ -324,6 +351,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, threadContactNameInternal: self.threadContactNameInternal, rowId: self.rowId, id: self.id, + openGroupServerMessageId: self.openGroupServerMessageId, variant: self.variant, timestampMs: self.timestampMs, receivedAtTimestampMs: self.receivedAtTimestampMs, @@ -374,9 +402,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, authorName: authorDisplayName, senderName: { // Only show for group threads - guard self.threadVariant == .openGroup || self.threadVariant == .closedGroup else { - return nil - } + guard isGroupThread else { return nil } // Only show for incoming messages guard self.variant == .standardIncoming || self.variant == .standardIncomingDeleted else { @@ -390,9 +416,14 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, return authorDisplayName }(), + canHaveProfile: ( + // Only group threads and incoming messages + isGroupThread && + self.variant == .standardIncoming + ), shouldShowProfile: ( // Only group threads - (self.threadVariant == .openGroup || self.threadVariant == .closedGroup) && + isGroupThread && // Only incoming messages (self.variant == .standardIncoming || self.variant == .standardIncomingDeleted) && @@ -415,7 +446,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isOnlyMessageInCluster: isOnlyMessageInCluster, isLast: isLast, isLastOutgoing: isLastOutgoing, - currentUserBlindedPublicKey: currentUserBlindedPublicKey + currentUserBlinded15PublicKey: currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: currentUserBlinded25PublicKey ) } } @@ -501,8 +533,9 @@ public extension MessageViewModel { public extension MessageViewModel { static let genericId: Int64 = -1 static let typingIndicatorId: Int64 = -2 + static let optimisticUpdateId: Int64 = -3 - // Note: This init method is only used system-created cells or empty states + /// This init method is only used for system-created cells or empty states init( variant: Interaction.Variant = .standardOutgoing, timestampMs: Int64 = Int64.max, @@ -532,6 +565,7 @@ public extension MessageViewModel { }() self.rowId = targetId self.id = targetId + self.openGroupServerMessageId = nil self.variant = variant self.timestampMs = timestampMs self.receivedAtTimestampMs = receivedAtTimestampMs @@ -553,14 +587,15 @@ public extension MessageViewModel { self.linkPreview = nil self.linkPreviewAttachment = nil self.currentUserPublicKey = "" + self.attachments = nil + self.reactionInfo = nil // Post-Query Processing Data - self.attachments = nil - self.reactionInfo = nil self.cellType = cellType self.authorName = "" self.senderName = nil + self.canHaveProfile = false self.shouldShowProfile = false self.shouldShowDateHeader = false self.containsOnlyEmoji = nil @@ -570,7 +605,87 @@ public extension MessageViewModel { self.isOnlyMessageInCluster = true self.isLast = isLast self.isLastOutgoing = isLastOutgoing - self.currentUserBlindedPublicKey = nil + self.currentUserBlinded15PublicKey = nil + self.currentUserBlinded25PublicKey = nil + } + + /// This init method is only used for optimistic outgoing messages + init( + threadId: String, + threadVariant: SessionThread.Variant, + threadHasDisappearingMessagesEnabled: Bool, + threadOpenGroupServer: String?, + threadOpenGroupPublicKey: String?, + threadContactNameInternal: String, + timestampMs: Int64, + receivedAtTimestampMs: Int64, + authorId: String, + authorNameInternal: String, + body: String?, + expiresStartedAtMs: Double?, + expiresInSeconds: TimeInterval?, + isSenderOpenGroupModerator: Bool, + currentUserProfile: Profile, + quote: Quote?, + quoteAttachment: Attachment?, + linkPreview: LinkPreview?, + linkPreviewAttachment: Attachment?, + attachments: [Attachment]? + ) { + self.threadId = threadId + self.threadVariant = threadVariant + self.threadIsTrusted = false + self.threadHasDisappearingMessagesEnabled = threadHasDisappearingMessagesEnabled + self.threadOpenGroupServer = threadOpenGroupServer + self.threadOpenGroupPublicKey = threadOpenGroupPublicKey + self.threadContactNameInternal = threadContactNameInternal + + // Interaction Info + + self.rowId = MessageViewModel.optimisticUpdateId + self.id = MessageViewModel.optimisticUpdateId + self.openGroupServerMessageId = nil + self.variant = .standardOutgoing + self.timestampMs = timestampMs + self.receivedAtTimestampMs = receivedAtTimestampMs + self.authorId = authorId + self.authorNameInternal = authorNameInternal + self.body = body + self.rawBody = body + self.expiresStartedAtMs = expiresStartedAtMs + self.expiresInSeconds = expiresInSeconds + + self.state = .sending + self.hasAtLeastOneReadReceipt = false + self.mostRecentFailureText = nil + self.isSenderOpenGroupModerator = isSenderOpenGroupModerator + self.isTypingIndicator = false + self.profile = currentUserProfile + self.quote = quote + self.quoteAttachment = quoteAttachment + self.linkPreview = linkPreview + self.linkPreviewAttachment = linkPreviewAttachment + self.currentUserPublicKey = currentUserProfile.id + self.attachments = attachments + self.reactionInfo = nil + + // Post-Query Processing Data + + self.cellType = .textOnlyMessage + self.authorName = "" + self.senderName = nil + self.canHaveProfile = false + self.shouldShowProfile = false + self.shouldShowDateHeader = false + self.containsOnlyEmoji = nil + self.glyphCount = nil + self.previousVariant = nil + self.positionInCluster = .middle + self.isOnlyMessageInCluster = true + self.isLast = false + self.isLastOutgoing = false + self.currentUserBlinded15PublicKey = nil + self.currentUserBlinded25PublicKey = nil } } @@ -637,7 +752,8 @@ public extension MessageViewModel { static func baseQuery( userPublicKey: String, - blindedPublicKey: String?, + blinded15PublicKey: String?, + blinded25PublicKey: String?, orderSQL: SQL, groupSQL: SQL? ) -> (([Int64]) -> AdaptedFetchRequest>) { @@ -673,7 +789,7 @@ public extension MessageViewModel { let interactionAttachmentAttachmentIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) let interactionAttachmentAlbumIndexColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) - let numColumnsBeforeLinkedRecords: Int = 21 + let numColumnsBeforeLinkedRecords: Int = 22 let finalGroupSQL: SQL = (groupSQL ?? "") let request: SQLRequest = """ SELECT @@ -689,6 +805,7 @@ public extension MessageViewModel { \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(interaction[.id]), + \(interaction[.openGroupServerMessageId]), \(interaction[.variant]), \(interaction[.timestampMs]), \(interaction[.receivedAtTimestampMs]), @@ -709,7 +826,7 @@ public extension MessageViewModel { WHERE ( \(groupMember[.groupId]) = \(interaction[.threadId]) AND \(groupMember[.profileId]) = \(interaction[.authorId]) AND - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(SQL("\(groupMember[.role]) IN \([GroupMember.Role.moderator, GroupMember.Role.admin])")) ) ) AS \(ViewModel.isSenderOpenGroupModeratorKey), @@ -730,6 +847,7 @@ public extension MessageViewModel { -- query from crashing when decoding we need to provide default values \(CellType.textOnlyMessage) AS \(ViewModel.cellTypeKey), '' AS \(ViewModel.authorNameKey), + false AS \(ViewModel.canHaveProfileKey), false AS \(ViewModel.shouldShowProfileKey), false AS \(ViewModel.shouldShowDateHeaderKey), \(Position.middle) AS \(ViewModel.positionInClusterKey), @@ -750,8 +868,11 @@ public extension MessageViewModel { \(quoteInteraction).\(authorIdColumn) = \(quote[.authorId]) OR ( -- A users outgoing message is stored in some cases using their standard id -- but the quote will use their blinded id so handle that case - \(quote[.authorId]) = \(blindedPublicKey ?? "''") AND - \(quoteInteraction).\(authorIdColumn) = \(userPublicKey) + \(quoteInteraction).\(authorIdColumn) = \(userPublicKey) AND + ( + \(quote[.authorId]) = \(blinded15PublicKey ?? "''") OR + \(quote[.authorId]) = \(blinded25PublicKey ?? "''") + ) ) ) ) @@ -871,11 +992,9 @@ public extension MessageViewModel.AttachmentInteractionInfo { updatedPagedDataCache = updatedPagedDataCache.upserting( dataToUpdate.with( - attachments: .update( - attachments - .sorted() - .map { $0.attachment } - ) + attachments: attachments + .sorted() + .map { $0.attachment } ) ) } @@ -953,7 +1072,7 @@ public extension MessageViewModel.ReactionInfo { else { return } updatedPagedDataCache = updatedPagedDataCache.upserting( - dataToUpdate.with(reactionInfo: .update(reactionInfo.sorted())) + dataToUpdate.with(reactionInfo: reactionInfo.sorted()) ) pagedRowIdsWithNoReactions.remove(interactionRowId) } @@ -963,7 +1082,7 @@ public extension MessageViewModel.ReactionInfo { items: pagedRowIdsWithNoReactions .compactMap { rowId -> ViewModel? in updatedPagedDataCache.data[rowId] } .filter { viewModel -> Bool in (viewModel.reactionInfo?.isEmpty == false) } - .map { viewModel -> ViewModel in viewModel.with(reactionInfo: nil) } + .map { viewModel -> ViewModel in viewModel.with(reactionInfo: []) } ) return updatedPagedDataCache diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 793c07aaf..0ee0f5f5a 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -24,14 +24,16 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat 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 threadPinnedPriorityKey: SQL = SQL(stringLiteral: CodingKeys.threadPinnedPriority.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 threadWasMarkedUnreadKey: SQL = SQL(stringLiteral: CodingKeys.threadWasMarkedUnread.stringValue) public static let threadUnreadCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadCount.stringValue) public static let threadUnreadMentionCountKey: SQL = SQL(stringLiteral: CodingKeys.threadUnreadMentionCount.stringValue) + public static let disappearingMessagesConfigurationKey: SQL = SQL(stringLiteral: CodingKeys.disappearingMessagesConfiguration.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) @@ -60,10 +62,12 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public static let authorNameInternalKey: SQL = SQL(stringLiteral: CodingKeys.authorNameInternal.stringValue) public static let currentUserPublicKeyKey: SQL = SQL(stringLiteral: CodingKeys.currentUserPublicKey.stringValue) + public static let threadWasMarkedUnreadString: String = CodingKeys.threadWasMarkedUnread.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 disappearingMessagesConfigurationString: String = CodingKeys.disappearingMessagesConfiguration.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 @@ -87,32 +91,35 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat /// 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 threadPinnedPriority: Int32 public let threadIsBlocked: Bool? public let threadMutedUntilTimestamp: TimeInterval? public let threadOnlyNotifyForMentions: Bool? public let threadMessageDraft: String? public let threadContactIsTyping: Bool? + public let threadWasMarkedUnread: Bool? public let threadUnreadCount: UInt? public let threadUnreadMentionCount: UInt? public var canWrite: Bool { switch threadVariant { case .contact: return true - case .closedGroup: + case .legacyGroup, .group: return ( currentUserIsClosedGroupMember == true && interactionVariant?.isGroupLeavingStatus != true ) - case .openGroup: + case .community: return (openGroupPermissions?.contains(.write) ?? false) } } // Thread display info + public let disappearingMessagesConfiguration: DisappearingMessagesConfiguration? + private let contactProfile: Profile? private let closedGroupProfileFront: Profile? private let closedGroupProfileBack: Profile? @@ -133,7 +140,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public let interactionId: Int64? public let interactionVariant: Interaction.Variant? - private let interactionTimestampMs: Int64? + public let interactionTimestampMs: Int64? public let interactionBody: String? public let interactionState: RecipientState.State? public let interactionHasAtLeastOneReadReceipt: Bool? @@ -145,7 +152,8 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat private let threadContactNameInternal: String? private let authorNameInternal: String? public let currentUserPublicKey: String - public let currentUserBlindedPublicKey: String? + public let currentUserBlinded15PublicKey: String? + public let currentUserBlinded25PublicKey: String? public let recentReactionEmoji: [String]? // UI specific logic @@ -164,14 +172,15 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var profile: Profile? { switch threadVariant { case .contact: return contactProfile - case .closedGroup: return (closedGroupProfileBack ?? closedGroupProfileBackFallback) - case .openGroup: return nil + case .legacyGroup, .group: + return (closedGroupProfileBack ?? closedGroupProfileBackFallback) + case .community: return nil } } public var additionalProfile: Profile? { switch threadVariant { - case .closedGroup: return closedGroupProfileFront + case .legacyGroup, .group: return closedGroupProfileFront default: return nil } } @@ -196,8 +205,8 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var userCount: Int? { switch threadVariant { case .contact: return nil - case .closedGroup: return closedGroupUserCount - case .openGroup: return openGroupUserCount + case .legacyGroup, .group: return closedGroupUserCount + case .community: return openGroupUserCount } } @@ -233,25 +242,117 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat ) ) } + + // MARK: - Marking as Read + + public enum ReadTarget { + /// Only the thread should be marked as read + case thread + + /// Both the thread and interactions should be marked as read, if no interaction id is provided then all interactions for the + /// thread will be marked as read + case threadAndInteractions(interactionsBeforeInclusive: Int64?) + } + + /// This method marks a thread as read and depending on the target may also update the interactions within a thread as read + public func markAsRead(target: ReadTarget) { + // Store the logic to mark a thread as read (to paths need to run this) + let threadId: String = self.threadId + let threadWasMarkedUnread: Bool? = self.threadWasMarkedUnread + let markThreadAsReadIfNeeded: () -> () = { + // Only make this change if needed (want to avoid triggering a thread update + // if not needed) + guard threadWasMarkedUnread == true else { return } + + Storage.shared.writeAsync { db in + try SessionThread + .filter(id: threadId) + .updateAllAndConfig( + db, + SessionThread.Columns.markedAsUnread.set(to: false) + ) + } + } + + // Determine what we want to mark as read + switch target { + // Only mark the thread as read + case .thread: markThreadAsReadIfNeeded() + + // We want to mark both the thread and interactions as read + case .threadAndInteractions(let interactionId): + guard + (self.threadUnreadCount ?? 0) > 0, + let targetInteractionId: Int64 = (interactionId ?? self.interactionId) + else { + // No unread interactions so just mark the thread as read if needed + markThreadAsReadIfNeeded() + return + } + + let threadId: String = self.threadId + let threadVariant: SessionThread.Variant = self.threadVariant + let threadIsBlocked: Bool? = self.threadIsBlocked + let threadIsMessageRequest: Bool? = self.threadIsMessageRequest + + Storage.shared.writeAsync { db in + markThreadAsReadIfNeeded() + + try Interaction.markAsRead( + db, + interactionId: targetInteractionId, + threadId: threadId, + threadVariant: threadVariant, + includingOlder: true, + trySendReadReceipt: try SessionThread.canSendReadReceipt( + db, + threadId: threadId, + threadVariant: threadVariant, + isBlocked: threadIsBlocked, + isMessageRequest: threadIsMessageRequest + ) + ) + } + } + } + + /// This method will mark a thread as read + public func markAsUnread() { + guard self.threadWasMarkedUnread != true else { return } + + let threadId: String = self.threadId + + Storage.shared.writeAsync { db in + try SessionThread + .filter(id: threadId) + .updateAllAndConfig( + db, + SessionThread.Columns.markedAsUnread.set(to: true) + ) + } + } } // MARK: - Convenience Initialization public extension SessionThreadViewModel { static let invalidId: String = "INVALID_THREAD_ID" + static let messageRequestsSectionId: String = "MESSAGE_REQUESTS_SECTION_INVALID_THREAD_ID" // Note: This init method is only used system-created cells or empty states init( - threadId: String? = nil, + threadId: String, threadVariant: SessionThread.Variant? = nil, threadIsNoteToSelf: Bool = false, + threadIsBlocked: Bool? = nil, contactProfile: Profile? = nil, currentUserIsClosedGroupMember: Bool? = nil, openGroupPermissions: OpenGroup.Permissions? = nil, - unreadCount: UInt = 0 + unreadCount: UInt = 0, + disappearingMessagesConfiguration: DisappearingMessagesConfiguration? = nil ) { self.rowId = -1 - self.threadId = (threadId ?? SessionThreadViewModel.invalidId) + self.threadId = threadId self.threadVariant = (threadVariant ?? .contact) self.threadCreationDateTimestamp = 0 self.threadMemberNames = nil @@ -260,18 +361,21 @@ public extension SessionThreadViewModel { self.threadIsMessageRequest = false self.threadRequiresApproval = false self.threadShouldBeVisible = false - self.threadIsPinned = false - self.threadIsBlocked = nil + self.threadPinnedPriority = 0 + self.threadIsBlocked = threadIsBlocked self.threadMutedUntilTimestamp = nil self.threadOnlyNotifyForMentions = nil self.threadMessageDraft = nil self.threadContactIsTyping = nil + self.threadWasMarkedUnread = nil self.threadUnreadCount = unreadCount self.threadUnreadMentionCount = nil // Thread display info + self.disappearingMessagesConfiguration = disappearingMessagesConfiguration + self.contactProfile = contactProfile self.closedGroupProfileFront = nil self.closedGroupProfileBack = nil @@ -304,7 +408,8 @@ public extension SessionThreadViewModel { self.threadContactNameInternal = nil self.authorNameInternal = nil self.currentUserPublicKey = getUserHexEncodedPublicKey() - self.currentUserBlindedPublicKey = nil + self.currentUserBlinded15PublicKey = nil + self.currentUserBlinded25PublicKey = nil self.recentReactionEmoji = nil } } @@ -325,14 +430,16 @@ public extension SessionThreadViewModel { threadIsMessageRequest: self.threadIsMessageRequest, threadRequiresApproval: self.threadRequiresApproval, threadShouldBeVisible: self.threadShouldBeVisible, - threadIsPinned: self.threadIsPinned, + threadPinnedPriority: self.threadPinnedPriority, threadIsBlocked: self.threadIsBlocked, threadMutedUntilTimestamp: self.threadMutedUntilTimestamp, threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions, threadMessageDraft: self.threadMessageDraft, threadContactIsTyping: self.threadContactIsTyping, + threadWasMarkedUnread: self.threadWasMarkedUnread, threadUnreadCount: self.threadUnreadCount, threadUnreadMentionCount: self.threadUnreadMentionCount, + disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, contactProfile: self.contactProfile, closedGroupProfileFront: self.closedGroupProfileFront, closedGroupProfileBack: self.closedGroupProfileBack, @@ -361,14 +468,16 @@ public extension SessionThreadViewModel { threadContactNameInternal: self.threadContactNameInternal, authorNameInternal: self.authorNameInternal, currentUserPublicKey: self.currentUserPublicKey, - currentUserBlindedPublicKey: self.currentUserBlindedPublicKey, + currentUserBlinded15PublicKey: self.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: self.currentUserBlinded25PublicKey, recentReactionEmoji: (recentReactionEmoji ?? self.recentReactionEmoji) ) } - func populatingCurrentUserBlindedKey( + func populatingCurrentUserBlindedKeys( _ db: Database? = nil, - currentUserBlindedPublicKeyForThisThread: String? = nil + currentUserBlinded15PublicKeyForThisThread: String? = nil, + currentUserBlinded25PublicKeyForThisThread: String? = nil ) -> SessionThreadViewModel { return SessionThreadViewModel( rowId: self.rowId, @@ -380,14 +489,16 @@ public extension SessionThreadViewModel { threadIsMessageRequest: self.threadIsMessageRequest, threadRequiresApproval: self.threadRequiresApproval, threadShouldBeVisible: self.threadShouldBeVisible, - threadIsPinned: self.threadIsPinned, + threadPinnedPriority: self.threadPinnedPriority, threadIsBlocked: self.threadIsBlocked, threadMutedUntilTimestamp: self.threadMutedUntilTimestamp, threadOnlyNotifyForMentions: self.threadOnlyNotifyForMentions, threadMessageDraft: self.threadMessageDraft, threadContactIsTyping: self.threadContactIsTyping, + threadWasMarkedUnread: self.threadWasMarkedUnread, threadUnreadCount: self.threadUnreadCount, threadUnreadMentionCount: self.threadUnreadMentionCount, + disappearingMessagesConfiguration: self.disappearingMessagesConfiguration, contactProfile: self.contactProfile, closedGroupProfileFront: self.closedGroupProfileFront, closedGroupProfileBack: self.closedGroupProfileBack, @@ -416,12 +527,22 @@ public extension SessionThreadViewModel { threadContactNameInternal: self.threadContactNameInternal, authorNameInternal: self.authorNameInternal, currentUserPublicKey: self.currentUserPublicKey, - currentUserBlindedPublicKey: ( - currentUserBlindedPublicKeyForThisThread ?? + currentUserBlinded15PublicKey: ( + currentUserBlinded15PublicKeyForThisThread ?? SessionThread.getUserHexEncodedBlindedKey( db, threadId: self.threadId, - threadVariant: self.threadVariant + threadVariant: self.threadVariant, + blindingPrefix: .blinded15 + ) + ), + currentUserBlinded25PublicKey: ( + currentUserBlinded25PublicKeyForThisThread ?? + SessionThread.getUserHexEncodedBlindedKey( + db, + threadId: self.threadId, + threadVariant: self.threadVariant, + blindingPrefix: .blinded25 ) ), recentReactionEmoji: self.recentReactionEmoji @@ -474,7 +595,7 @@ public extension SessionThreadViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 12 + let numColumnsBeforeProfiles: Int = 14 let numColumnsBetweenProfilesAndAttachmentInfo: Int = 12 // The attachment info columns will be combined let request: SQLRequest = """ SELECT @@ -484,12 +605,18 @@ public extension SessionThreadViewModel { \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), - + ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) AND + \(SQL("\(thread[.id]) != \(userPublicKey)")) AND + IFNULL(\(contact[.isApproved]), false) = false + ) AS \(ViewModel.threadIsMessageRequestKey), + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), + \(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey), \(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey), \(aggregateInteractionLiteral).\(ViewModel.threadUnreadMentionCountKey), @@ -679,7 +806,6 @@ public extension SessionThreadViewModel { static func homeFilterSQL(userPublicKey: String) -> SQL { let thread: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() - let interaction: TypedTableAlias = TypedTableAlias() return """ \(thread[.shouldBeVisible]) = true AND ( @@ -687,10 +813,6 @@ public extension SessionThreadViewModel { \(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[.timestampMs]) IS NOT NULL ) """ } @@ -720,8 +842,8 @@ public extension SessionThreadViewModel { let interaction: TypedTableAlias = TypedTableAlias() return SQL(""" - \(thread[.isPinned]) DESC, - CASE WHEN \(interaction[.timestampMs]) IS NOT NULL THEN \(interaction[.timestampMs]) ELSE (\(thread[.creationDateTimestamp]) * 1000) END DESC + (IFNULL(\(thread[.pinnedPriority]), 0) > 0) DESC, + IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC """) }() @@ -740,6 +862,7 @@ public extension SessionThreadViewModel { /// but including this warning just in case there is a discrepancy) static func conversationQuery(threadId: String, userPublicKey: String) -> AdaptedFetchRequest> { let thread: TypedTableAlias = TypedTableAlias() + let disappearingMessagesConfiguration: TypedTableAlias = TypedTableAlias() let contact: TypedTableAlias = TypedTableAlias() let closedGroup: TypedTableAlias = TypedTableAlias() let groupMember: TypedTableAlias = TypedTableAlias() @@ -747,7 +870,6 @@ public extension SessionThreadViewModel { let interaction: TypedTableAlias = TypedTableAlias() let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") - let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) let closedGroupUserCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.closedGroupUserCountString)_table") let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) @@ -757,7 +879,7 @@ public extension SessionThreadViewModel { /// parse and might throw /// /// Explicitly set default values for the fields ignored for search results - let numColumnsBeforeProfiles: Int = 14 + let numColumnsBeforeProfiles: Int = 15 let request: SQLRequest = """ SELECT \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), @@ -777,13 +899,16 @@ public extension SessionThreadViewModel { ) AS \(ViewModel.threadRequiresApprovalKey), \(thread[.shouldBeVisible]) AS \(ViewModel.threadShouldBeVisibleKey), - \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), - + + \(thread[.markedAsUnread]) AS \(ViewModel.threadWasMarkedUnreadKey), \(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey), + + \(ViewModel.disappearingMessagesConfigurationKey).*, \(ViewModel.contactProfileKey).*, \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), @@ -807,16 +932,18 @@ public extension SessionThreadViewModel { \(openGroup[.permissions]) AS \(ViewModel.openGroupPermissionsKey), \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey), + \(aggregateInteractionLiteral).\(ViewModel.interactionTimestampMsKey), \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) FROM \(SessionThread.self) + LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfiguration[.threadId]) = \(thread[.id]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN ( SELECT \(interaction[.id]) AS \(ViewModel.interactionIdKey), \(interaction[.threadId]) AS \(ViewModel.threadIdKey), - MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral), + MAX(\(interaction[.timestampMs])) AS \(ViewModel.interactionTimestampMsKey), SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey) FROM \(Interaction.self) WHERE ( @@ -845,11 +972,13 @@ public extension SessionThreadViewModel { return request.adapted { db in let adapters = try splittingRowAdapters(columnCounts: [ numColumnsBeforeProfiles, + DisappearingMessagesConfiguration.numberOfSelectedColumns(db), Profile.numberOfSelectedColumns(db) ]) return ScopeAdapter([ - ViewModel.contactProfileString: adapters[1] + ViewModel.disappearingMessagesConfigurationString: adapters[1], + ViewModel.contactProfileString: adapters[2] ]) } } @@ -879,7 +1008,7 @@ public extension SessionThreadViewModel { (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), @@ -996,20 +1125,30 @@ public extension SessionThreadViewModel { /// 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 } + /// Step 4 - Append a wild-card character to the final word (as long as the last word doesn't end in a quote) + let normalisedTerm: String = standardQuotes(searchTerm) + + guard let regex = try? NSRegularExpression(pattern: "[^\\s\"']+|\"([^\"]*)\"") else { + // Fallback to removing the quotes and just splitting on spaces + return normalisedTerm + .replacingOccurrences(of: "\"", with: "") + .split(separator: " ") + .map { "\"\($0)\"" } + .filter { !$0.isEmpty } + } + + return regex + .matches(in: normalisedTerm, range: NSRange(location: 0, length: normalisedTerm.count)) + .compactMap { Range($0.range, in: normalisedTerm) } + .map { normalisedTerm[$0].trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } + .map { "\"\($0)\"" } + } + + static func standardQuotes(_ term: String) -> String { + // Apple like to use the special '”“' quote characters when typing so replace them with normal ones + return term + .replacingOccurrences(of: "”", with: "\"") + .replacingOccurrences(of: "“", with: "\"") } static func pattern(_ db: Database, searchTerm: String) throws -> FTS5Pattern { @@ -1019,22 +1158,31 @@ public extension SessionThreadViewModel { static func pattern(_ db: Database, searchTerm: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { // 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("*") + let rawPattern: String = { + let result: String = searchTermParts(searchTerm) + .joined(separator: " OR ") + + // If the last character is a quotation mark then assume the user doesn't want to append + // a wildcard character + guard !standardQuotes(searchTerm).hasSuffix("\"") else { return result } + + return "\(result)*" + }() let fallbackTerm: String = "\(searchSafeTerm(searchTerm))*" /// 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: table)) - .defaulting( - to: (try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table)) - .defaulting(to: FTS5Pattern(matchingAnyTokenIn: fallbackTerm)) - ) - - guard let pattern: FTS5Pattern = maybePattern else { throw StorageError.invalidSearchPattern } - - return pattern + return try { + if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: rawPattern, forTable: table) { + return pattern + } + + if let pattern: FTS5Pattern = try? db.makeFTS5Pattern(rawPattern: fallbackTerm, forTable: table) { + return pattern + } + + return try FTS5Pattern(matchingAnyTokenIn: fallbackTerm) ?? { throw StorageError.invalidSearchPattern }() + }() } static func messagesQuery(userPublicKey: String, pattern: FTS5Pattern) -> AdaptedFetchRequest> { @@ -1062,7 +1210,7 @@ public extension SessionThreadViewModel { \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), \(ViewModel.contactProfileKey).*, \(ViewModel.closedGroupProfileFrontKey).*, @@ -1075,7 +1223,7 @@ public extension SessionThreadViewModel { \(interaction[.id]) AS \(ViewModel.interactionIdKey), \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), - \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + snippet(\(interactionFullTextSearch), -1, '', '', '...', 6) AS \(ViewModel.interactionBodyKey), \(interaction[.authorId]), IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), @@ -1156,12 +1304,18 @@ public extension SessionThreadViewModel { /// - Closed group member name /// - Open group name /// - "Note to self" text match + /// - Hidden contact nickname + /// - Hidden contact name + /// + /// **Note 2:** Since the "Hidden Contact" records don't have associated threads the `rowId` value in the + /// returned results will always be `-1` for those results 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 contact: 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) @@ -1199,7 +1353,7 @@ public extension SessionThreadViewModel { \(groupMemberInfoLiteral).\(ViewModel.threadMemberNamesKey), (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), \(ViewModel.contactProfileKey).*, \(ViewModel.closedGroupProfileFrontKey).*, @@ -1310,7 +1464,10 @@ public extension SessionThreadViewModel { LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON false LEFT JOIN \(OpenGroup.self) ON false - WHERE \(SQL("\(thread[.variant]) = \(SessionThread.Variant.closedGroup)")) + WHERE ( + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.legacyGroup)")) OR + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.group)")) + ) GROUP BY \(thread[.id]) """ @@ -1388,7 +1545,7 @@ public extension SessionThreadViewModel { ) AS \(groupMemberInfoLiteral) ON false WHERE - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND \(SQL("\(thread[.id]) != \(userPublicKey)")) GROUP BY \(thread[.id]) """ @@ -1469,6 +1626,83 @@ public extension SessionThreadViewModel { WHERE \(SQL("\(thread[.id]) = \(userPublicKey)")) """ + // MARK: --Contacts without threads + let hiddenContactQuery: SQL = """ + SELECT + IFNULL(\(Column.rank), 100) AS \(Column.rank), + + -1 AS \(ViewModel.rowIdKey), + \(contact[.id]) AS \(ViewModel.threadIdKey), + \(SQL("\(SessionThread.Variant.contact)")) AS \(ViewModel.threadVariantKey), + 0 AS \(ViewModel.threadCreationDateTimestampKey), + \(groupMemberInfoLiteral).\(ViewModel.threadMemberNamesKey), + + false AS \(ViewModel.threadIsNoteToSelfKey), + -1 AS \(ViewModel.threadPinnedPriorityKey), + + \(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 \(Contact.self) + """ + let hiddenContactQueryCommonJoins: SQL = """ + JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(contact[.id]) + LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(contact[.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 \(thread[.id]) IS NULL + GROUP BY \(contact[.id]) + """ + + // Hidden contact by nickname + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += hiddenContactQuery + sqlQuery += """ + + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNicknameColumnLiteral) MATCH \(pattern) + ) + """ + sqlQuery += hiddenContactQueryCommonJoins + + // Hidden contact by name + sqlQuery += """ + + UNION ALL + + """ + sqlQuery += hiddenContactQuery + sqlQuery += """ + + JOIN \(profileFullTextSearch) ON ( + \(profileFullTextSearch).rowid = \(ViewModel.contactProfileKey).rowid AND + \(profileFullTextSearch).\(profileNameColumnLiteral) MATCH \(pattern) + ) + """ + sqlQuery += hiddenContactQueryCommonJoins + // 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 @@ -1543,7 +1777,7 @@ public extension SessionThreadViewModel { '' AS \(ViewModel.threadMemberNamesKey), true AS \(ViewModel.threadIsNoteToSelfKey), - \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), \(ViewModel.contactProfileKey).*, @@ -1601,7 +1835,7 @@ public extension SessionThreadViewModel { (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), - \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), + IFNULL(\(thread[.pinnedPriority]), 0) AS \(ViewModel.threadPinnedPriorityKey), \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), \(ViewModel.contactProfileKey).*, diff --git a/SessionMessagingKit/Utilities/AppReadiness.h b/SessionMessagingKit/Utilities/AppReadiness.h index 617892b00..2196cb68c 100755 --- a/SessionMessagingKit/Utilities/AppReadiness.h +++ b/SessionMessagingKit/Utilities/AppReadiness.h @@ -15,7 +15,8 @@ typedef void (^AppReadyBlock)(void); // This method can be called on any thread. + (BOOL)isAppReady; -// This method should only be called on the main thread. +// These methods should only be called on the main thread. ++ (void)invalidate; + (void)setAppIsReady; // If the app is ready, the block is called immediately; diff --git a/SessionMessagingKit/Utilities/AppReadiness.m b/SessionMessagingKit/Utilities/AppReadiness.m index 300f13f43..0af06e673 100755 --- a/SessionMessagingKit/Utilities/AppReadiness.m +++ b/SessionMessagingKit/Utilities/AppReadiness.m @@ -54,9 +54,14 @@ NS_ASSUME_NONNULL_BEGIN + (void)runNowOrWhenAppWillBecomeReady:(AppReadyBlock)block { - DispatchMainThreadSafe(^{ + if ([NSThread isMainThread]) { [self.sharedManager runNowOrWhenAppWillBecomeReady:block]; - }); + } + else { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.sharedManager runNowOrWhenAppWillBecomeReady:block]; + }); + } } - (void)runNowOrWhenAppWillBecomeReady:(AppReadyBlock)block @@ -76,9 +81,14 @@ NS_ASSUME_NONNULL_BEGIN + (void)runNowOrWhenAppDidBecomeReady:(AppReadyBlock)block { - DispatchMainThreadSafe(^{ + if ([NSThread isMainThread]) { [self.sharedManager runNowOrWhenAppDidBecomeReady:block]; - }); + } + else { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.sharedManager runNowOrWhenAppDidBecomeReady:block]; + }); + } } - (void)runNowOrWhenAppDidBecomeReady:(AppReadyBlock)block @@ -96,6 +106,16 @@ NS_ASSUME_NONNULL_BEGIN [self.appDidBecomeReadyBlocks addObject:block]; } ++ (void)invalidate +{ + [self.sharedManager invalidate]; +} + +- (void)invalidate +{ + self.isAppReady = NO; +} + + (void)setAppIsReady { [self.sharedManager setAppIsReady]; diff --git a/SessionMessagingKit/Utilities/Data+Utilities.swift b/SessionMessagingKit/Utilities/Data+Utilities.swift index 967e8a263..47b3e074e 100644 --- a/SessionMessagingKit/Utilities/Data+Utilities.swift +++ b/SessionMessagingKit/Utilities/Data+Utilities.swift @@ -5,23 +5,7 @@ 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 - } - } - func removePadding() -> Data { let bytes: [UInt8] = self.bytes var paddingStart: Int = self.count diff --git a/SessionMessagingKit/Utilities/DeviceSleepManager.swift b/SessionMessagingKit/Utilities/DeviceSleepManager.swift index ff0d470b8..11ec81bc0 100644 --- a/SessionMessagingKit/Utilities/DeviceSleepManager.swift +++ b/SessionMessagingKit/Utilities/DeviceSleepManager.swift @@ -24,7 +24,7 @@ public class DeviceSleepManager: NSObject { return "SleepBlock(\(String(reflecting: blockObject)))" } - init(blockObject: NSObject) { + init(blockObject: NSObject?) { self.blockObject = blockObject } } @@ -51,14 +51,14 @@ public class DeviceSleepManager: NSObject { } @objc - public func addBlock(blockObject: NSObject) { + public func addBlock(blockObject: NSObject?) { blocks.append(SleepBlock(blockObject: blockObject)) ensureSleepBlocking() } @objc - public func removeBlock(blockObject: NSObject) { + public func removeBlock(blockObject: NSObject?) { blocks = blocks.filter { $0.blockObject != nil && $0.blockObject != blockObject } diff --git a/SessionMessagingKit/Utilities/Identity+Utilities.swift b/SessionMessagingKit/Utilities/Identity+Utilities.swift new file mode 100644 index 000000000..ca4d6e22b --- /dev/null +++ b/SessionMessagingKit/Utilities/Identity+Utilities.swift @@ -0,0 +1,19 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public extension Identity { + /// The user actually exists very early on during the onboarding process but there are also a few cases + /// where we want to know that the user is in a valid state (ie. has completed the proper onboarding + /// process), this value indicates that state + /// + /// One case which can happen is if the app crashed during onboarding the user can be left in an invalid + /// state (ie. with no display name) - the user would be asked to enter one on a subsequent launch to + /// resolve the invalid state + static func userCompletedRequiredOnboarding(_ db: Database? = nil) -> Bool { + Identity.userExists(db) && + !Profile.fetchOrCreateCurrentUser(db).name.isEmpty + } +} diff --git a/SessionMessagingKit/Utilities/OWSAES256Key+Utilities.swift b/SessionMessagingKit/Utilities/OWSAES256Key+Utilities.swift deleted file mode 100644 index 295c78ed9..000000000 --- a/SessionMessagingKit/Utilities/OWSAES256Key+Utilities.swift +++ /dev/null @@ -1,12 +0,0 @@ -// 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/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 0b3c91209..34a00860e 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -89,6 +89,12 @@ public extension Setting.DoubleKey { static let screenLockTimeoutSeconds: Setting.DoubleKey = "screenLockTimeoutSeconds" } +public extension Setting.IntKey { + /// This is the number of times the app has successfully become active, it's not actually used for anything but allows us to make + /// a database change on launch so the database will output an error if it fails to write + static let activeCounter: Setting.IntKey = "activeCounter" +} + public enum Preferences { public enum NotificationPreviewType: Int, CaseIterable, EnumIntSetting, Differentiable { public static var defaultPreviewType: NotificationPreviewType = .nameAndPreview diff --git a/SessionMessagingKit/Utilities/ProfileManager.swift b/SessionMessagingKit/Utilities/ProfileManager.swift index ba5212d30..5ffc3d937 100644 --- a/SessionMessagingKit/Utilities/ProfileManager.swift +++ b/SessionMessagingKit/Utilities/ProfileManager.swift @@ -1,16 +1,27 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import CryptoKit +import Combine import GRDB -import PromiseKit import SignalCoreKit import SessionUtilitiesKit public struct ProfileManager { + public enum AvatarUpdate { + case none + case remove + case uploadImageData(Data) + case updateTo(url: String, key: Data, fileName: String?) + } + // 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 = 64 public static let maxAvatarDiameter: CGFloat = 640 + private static let maxAvatarBytes: UInt = (5 * 1000 * 1000) + public static let avatarAES256KeyByteLength: Int = 32 + private static let avatarNonceLength: Int = 12 + private static let avatarTagLength: Int = 16 private static var profileAvatarCache: Atomic<[String: Data]> = Atomic([:]) private static var currentAvatarDownloads: Atomic> = Atomic([]) @@ -18,7 +29,11 @@ public struct ProfileManager { // MARK: - Functions public static func isToLong(profileName: String) -> Bool { - return ((profileName.data(using: .utf8)?.count ?? 0) > nameDataLength) + return (profileName.utf8CString.count > SessionUtil.libSessionMaxNameByteLength) + } + + public static func isToLong(profileUrl: String) -> Bool { + return (profileUrl.utf8CString.count > SessionUtil.libSessionMaxProfileUrlByteLength) } public static func profileAvatar(_ db: Database? = nil, id: String) -> Data? { @@ -36,7 +51,10 @@ public struct ProfileManager { } if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty { - downloadAvatar(for: profile) + // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this + JobRunner.afterBlockingQueue { + ProfileManager.downloadAvatar(for: profile) + } } return nil @@ -63,7 +81,10 @@ public struct ProfileManager { completion: { _, _ in // Try to re-download the avatar if it has a URL if let profilePictureUrl: String = profile.profilePictureUrl, !profilePictureUrl.isEmpty { - downloadAvatar(for: profile) + // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this + JobRunner.afterBlockingQueue { + ProfileManager.downloadAvatar(for: profile) + } } } ) @@ -74,7 +95,14 @@ public struct ProfileManager { return data } - private static func loadProfileData(with fileName: String) -> Data? { + public static func hasProfileImageData(with fileName: String?) -> Bool { + guard let fileName: String = fileName, !fileName.isEmpty else { return false } + + return FileManager.default + .fileExists(atPath: ProfileManager.profileAvatarFilepath(filename: fileName)) + } + + public static func loadProfileData(with fileName: String) -> Data? { let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName) return try? Data(contentsOf: URL(fileURLWithPath: filePath)) @@ -82,16 +110,40 @@ public struct ProfileManager { // MARK: - Profile Encryption - private static func encryptProfileData(data: Data, key: OWSAES256Key) -> Data? { - guard key.keyData.count == kAES256_KeyByteLength else { return nil } + private static func encryptData(data: Data, key: Data) -> Data? { + // The key structure is: nonce || ciphertext || authTag + guard + key.count == ProfileManager.avatarAES256KeyByteLength, + let nonceData: Data = try? Randomness.generateRandomBytes(numberBytes: ProfileManager.avatarNonceLength), + let nonce: AES.GCM.Nonce = try? AES.GCM.Nonce(data: nonceData), + let sealedData: AES.GCM.SealedBox = try? AES.GCM.seal( + data, + using: SymmetricKey(data: key), + nonce: nonce + ), + let encryptedContent: Data = sealedData.combined + else { return nil } - return Cryptography.encryptAESGCMProfileData(plainTextData: data, key: key) + return encryptedContent } - private static func decryptProfileData(data: Data, key: OWSAES256Key) -> Data? { - guard key.keyData.count == kAES256_KeyByteLength else { return nil } + private static func decryptData(data: Data, key: Data) -> Data? { + guard key.count == ProfileManager.avatarAES256KeyByteLength else { return nil } - return Cryptography.decryptAESGCMProfileData(encryptedData: data, key: key) + // The key structure is: nonce || ciphertext || authTag + let cipherTextLength: Int = (data.count - (ProfileManager.avatarNonceLength + ProfileManager.avatarTagLength)) + + guard + cipherTextLength > 0, + let sealedData: AES.GCM.SealedBox = try? AES.GCM.SealedBox( + nonce: AES.GCM.Nonce(data: data.subdata(in: 0.. 0 + let profileKeyAtStart: Data = profile.profileEncryptionKey, + profileKeyAtStart.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 + 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) + .subscribe(on: DispatchQueue.global(qos: .background)) + .receive(on: DispatchQueue.global(qos: .background)) + .sinkUntilComplete( + receiveCompletion: { _ 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 } + }, + receiveValue: { data in 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, + let latestProfileKey: Data = latestProfile.profileEncryptionKey, + !latestProfileKey.isEmpty, latestProfileKey == profileKeyAtStart else { OWSLogger.warn("Ignoring avatar download for obsolete user profile.") @@ -195,7 +253,7 @@ public struct ProfileManager { return } - guard let decryptedData: Data = decryptProfileData(data: data, key: profileKeyAtStart) else { + guard let decryptedData: Data = decryptData(data: data, key: profileKeyAtStart) else { OWSLogger.warn("Avatar data for \(profile.id) could not be decrypted.") return } @@ -207,27 +265,18 @@ public struct ProfileManager { return } + // Update the cache first (in case the DBWrite thread is blocked, this way other threads + // can retrieve from the cache and avoid triggering a download) + profileAvatarCache.mutate { $0[fileName] = decryptedData } + // 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 @@ -235,37 +284,117 @@ public struct ProfileManager { public static func updateLocal( queue: DispatchQueue, profileName: String, - image: UIImage?, - imageFilePath: String?, - success: ((Database, Profile) throws -> ())? = nil, + avatarUpdate: AvatarUpdate = .none, + success: ((Database) throws -> ())? = nil, + failure: ((ProfileManagerError) -> ())? = nil + ) { + let userPublicKey: String = getUserHexEncodedPublicKey() + let isRemovingAvatar: Bool = { + switch avatarUpdate { + case .remove: return true + default: return false + } + }() + + switch avatarUpdate { + case .none, .remove, .updateTo: + Storage.shared.writeAsync { db in + if isRemovingAvatar { + let existingProfileUrl: String? = try Profile + .filter(id: userPublicKey) + .select(.profilePictureUrl) + .asRequest(of: String.self) + .fetchOne(db) + let existingProfileFileName: String? = try Profile + .filter(id: userPublicKey) + .select(.profilePictureFileName) + .asRequest(of: String.self) + .fetchOne(db) + + // Remove any cached avatar image value + if let fileName: String = existingProfileFileName { + profileAvatarCache.mutate { $0[fileName] = nil } + } + + OWSLogger.verbose(existingProfileUrl != nil ? + "Updating local profile on service with cleared avatar." : + "Updating local profile on service with no avatar." + ) + } + + try ProfileManager.updateProfileIfNeeded( + db, + publicKey: userPublicKey, + name: profileName, + avatarUpdate: avatarUpdate, + sentTimestamp: Date().timeIntervalSince1970 + ) + + SNLog("Successfully updated service with profile.") + try success?(db) + } + + case .uploadImageData(let data): + prepareAndUploadAvatarImage( + queue: queue, + imageData: data, + success: { downloadUrl, fileName, newProfileKey in + Storage.shared.writeAsync { db in + try ProfileManager.updateProfileIfNeeded( + db, + publicKey: userPublicKey, + name: profileName, + avatarUpdate: .updateTo(url: downloadUrl, key: newProfileKey, fileName: fileName), + sentTimestamp: Date().timeIntervalSince1970 + ) + + SNLog("Successfully updated service with profile.") + try success?(db) + } + }, + failure: failure + ) + } + } + + private static func prepareAndUploadAvatarImage( + queue: DispatchQueue, + imageData: Data, + success: @escaping ((downloadUrl: String, fileName: String, profileKey: Data)) -> (), 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? + let newProfileKey: Data + let avatarImageData: Data + let fileExtension: String do { + let guessedFormat: ImageFormat = imageData.guessedImageFormat + 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 + switch guessedFormat { + case .gif, .webp: + // Animated images can't be resized so if the data is too large we should error + guard imageData.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 imageData + + default: break } + // Process the image to ensure it meets our standards for size and compress it to + // standardise the formwat and remove any metadata + guard var image: UIImage = UIImage(data: imageData) else { throw ProfileManagerError.invalidCall } + 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. @@ -289,47 +418,19 @@ public struct ProfileManager { 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 } + + newProfileKey = try Randomness.generateRandomBytes(numberBytes: ProfileManager.avatarAES256KeyByteLength) + fileExtension = { + switch guessedFormat { + case .gif: return "gif" + case .webp: return "webp" + default: return "jpg" } - - SNLog("Successfully updated service with profile.") - - try success?(db, updatedProfile) - } - return + }() } + // TODO: Test that this actually works + catch let error as ProfileManagerError { return (failure?(error) ?? {}()) } + catch { return (failure?(ProfileManagerError.invalidCall) ?? {}()) } // If we have a new avatar image, we must first: // @@ -339,16 +440,11 @@ public struct ProfileManager { // * 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 fileName: String = UUID().uuidString.appendingFileExtension(fileExtension) let filePath: String = ProfileManager.profileAvatarFilepath(filename: fileName) // Write the avatar to disk - do { try data.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) } + do { try avatarImageData.write(to: URL(fileURLWithPath: filePath), options: [.atomic]) } catch { SNLog("Updating service with profile failed.") failure?(.avatarWriteFailed) @@ -356,7 +452,7 @@ public struct ProfileManager { } // Encrypt the avatar for upload - guard let encryptedAvatarData: Data = encryptProfileData(data: data, key: newProfileKey) else { + guard let encryptedAvatarData: Data = encryptData(data: avatarImageData, key: newProfileKey) else { SNLog("Updating service with profile failed.") failure?(.avatarEncryptionFailed) return @@ -365,38 +461,152 @@ public struct ProfileManager { // 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) + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: queue) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + SNLog("Updating service with profile failed.") + + let isMaxFileSizeExceeded: Bool = ((error as? HTTPError) == .maxFileSizeExceeded) + failure?(isMaxFileSizeExceeded ? + .avatarUploadMaxFileSizeExceeded : + .avatarUploadFailed + ) + } + }, + receiveValue: { fileUploadResponse in + let downloadUrl: String = "\(FileServerAPI.server)/file/\(fileUploadResponse.id)" // Update the cached avatar image value - profileAvatarCache.mutate { $0[fileName] = data } + profileAvatarCache.mutate { $0[fileName] = avatarImageData } + UserDefaults.standard[.lastProfilePictureUpload] = Date() - SNLog("Successfully updated service with profile.") - try success?(db, profile) + SNLog("Successfully uploaded avatar image.") + success((downloadUrl, fileName, newProfileKey)) } - } - .recover(on: queue) { error in - SNLog("Updating service with profile failed.") + ) + } + } + + public static func updateProfileIfNeeded( + _ db: Database, + publicKey: String, + name: String?, + avatarUpdate: AvatarUpdate, + sentTimestamp: TimeInterval, + calledFromConfigHandling: Bool = false, + dependencies: Dependencies = Dependencies() + ) throws { + let isCurrentUser = (publicKey == getUserHexEncodedPublicKey(db, dependencies: dependencies)) + let profile: Profile = Profile.fetchOrCreate(db, id: publicKey) + var profileChanges: [ConfigColumnAssignment] = [] + + // Name + if let name: String = name, !name.isEmpty, name != profile.name { + // FIXME: Remove the `userConfigsEnabled` check once `useSharedUtilForUserConfig` is permanent + if sentTimestamp > profile.lastNameUpdate || (isCurrentUser && (calledFromConfigHandling || !SessionUtil.userConfigsEnabled(db))) { + profileChanges.append(Profile.Columns.name.set(to: name)) + profileChanges.append(Profile.Columns.lastNameUpdate.set(to: sentTimestamp)) + } + } + + // Profile picture & profile key + var avatarNeedsDownload: Bool = false + var targetAvatarUrl: String? = nil + + // FIXME: Remove the `userConfigsEnabled` check once `useSharedUtilForUserConfig` is permanent + if sentTimestamp > profile.lastProfilePictureUpdate || (isCurrentUser && (calledFromConfigHandling || !SessionUtil.userConfigsEnabled(db))) { + switch avatarUpdate { + case .none: break + case .uploadImageData: preconditionFailure("Invalid options for this function") - let isMaxFileSizeExceeded: Bool = ((error as? HTTP.Error) == HTTP.Error.maxFileSizeExceeded) - failure?(isMaxFileSizeExceeded ? - .avatarUploadMaxFileSizeExceeded : - .avatarUploadFailed + case .remove: + profileChanges.append(Profile.Columns.profilePictureUrl.set(to: nil)) + profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: nil)) + profileChanges.append(Profile.Columns.profilePictureFileName.set(to: nil)) + profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp)) + + case .updateTo(let url, let key, let fileName): + if url != profile.profilePictureUrl { + profileChanges.append(Profile.Columns.profilePictureUrl.set(to: url)) + avatarNeedsDownload = true + targetAvatarUrl = url + } + + if key != profile.profileEncryptionKey && key.count == ProfileManager.avatarAES256KeyByteLength { + profileChanges.append(Profile.Columns.profileEncryptionKey.set(to: key)) + } + + // Profile filename (this isn't synchronized between devices) + if let fileName: String = fileName { + profileChanges.append(Profile.Columns.profilePictureFileName.set(to: fileName)) + + // If we have already downloaded the image then no need to download it again + avatarNeedsDownload = ( + avatarNeedsDownload && + !ProfileManager.hasProfileImageData(with: fileName) + ) + } + + // Update the 'lastProfilePictureUpdate' timestamp for either external or local changes + profileChanges.append(Profile.Columns.lastProfilePictureUpdate.set(to: sentTimestamp)) + } + } + + // Persist any changes + if !profileChanges.isEmpty { + try profile.save(db) + + if calledFromConfigHandling { + try Profile + .filter(id: publicKey) + .updateAll( // Handling a config update so don't use `updateAllAndConfig` + db, + profileChanges ) + } + // FIXME: Remove this once `useSharedUtilForUserConfig` is permanent + else if !SessionUtil.userConfigsEnabled(db) { + // If we have a contact record for the profile (ie. it's a synced profile) then + // should should send an updated config message, otherwise we should just update + // the local state (the shared util has this logic build in to it's handling) + if (try? Contact.exists(db, id: publicKey)) == true { + try Profile + .filter(id: publicKey) + .updateAllAndConfig(db, profileChanges) } - .retainUntilComplete() + else { + try Profile + .filter(id: publicKey) + .updateAll( + db, + profileChanges + ) + } + } + else { + try Profile + .filter(id: publicKey) + .updateAllAndConfig(db, profileChanges) + } + } + + // Download the profile picture if needed + guard avatarNeedsDownload else { return } + + let dedupeIdentifier: String = "AvatarDownload-\(publicKey)-\(targetAvatarUrl ?? "remove")" + + db.afterNextTransactionNestedOnce(dedupeId: dedupeIdentifier) { db in + // Need to refetch to ensure the db changes have occurred + let targetProfile: Profile = Profile.fetchOrCreate(db, id: publicKey) + + // FIXME: Refactor avatar downloading to be a proper Job so we can avoid this + JobRunner.afterBlockingQueue { + ProfileManager.downloadAvatar(for: targetProfile) + } } } } diff --git a/SessionMessagingKit/Utilities/ProfileManagerError.swift b/SessionMessagingKit/Utilities/ProfileManagerError.swift index 1be60fbad..a522e492e 100644 --- a/SessionMessagingKit/Utilities/ProfileManagerError.swift +++ b/SessionMessagingKit/Utilities/ProfileManagerError.swift @@ -8,6 +8,7 @@ public enum ProfileManagerError: LocalizedError { case avatarEncryptionFailed case avatarUploadFailed case avatarUploadMaxFileSizeExceeded + case invalidCall var localizedDescription: String { switch self { @@ -16,6 +17,7 @@ public enum ProfileManagerError: LocalizedError { case .avatarEncryptionFailed: return "Avatar encryption failed." case .avatarUploadFailed: return "Avatar upload failed." case .avatarUploadMaxFileSizeExceeded: return "Maximum file size exceeded." + case .invalidCall: return "Attempted to remove avatar using the wrong method." } } } diff --git a/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift new file mode 100644 index 000000000..16958927c --- /dev/null +++ b/SessionMessagingKit/Utilities/ProfilePictureView+Convenience.swift @@ -0,0 +1,115 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUIKit + +public extension ProfilePictureView { + func update( + publicKey: String, + threadVariant: SessionThread.Variant, + customImageData: Data?, + profile: Profile?, + profileIcon: ProfileIcon = .none, + additionalProfile: Profile? = nil, + additionalProfileIcon: ProfileIcon = .none + ) { + // If we are given 'customImageData' then only use that + guard customImageData == nil else { return update(Info(imageData: customImageData)) } + + // Otherwise there are conversation-type-specific behaviours + switch threadVariant { + case .community: + let placeholderImage: UIImage = { + switch self.size { + case .navigation, .message: return #imageLiteral(resourceName: "SessionWhite16") + case .list: return #imageLiteral(resourceName: "SessionWhite24") + case .hero: return #imageLiteral(resourceName: "SessionWhite40") + } + }() + + update( + Info( + imageData: placeholderImage.pngData(), + inset: UIEdgeInsets( + top: 12, + left: 12, + bottom: 12, + right: 12 + ), + icon: profileIcon, + forcedBackgroundColor: .theme(.classicDark, color: .borderSeparator) + ) + ) + + case .legacyGroup, .group: + guard !publicKey.isEmpty else { return } + + update( + Info( + imageData: ( + profile.map { ProfileManager.profileAvatar(profile: $0) } ?? + PlaceholderIcon.generate( + seed: publicKey, + text: (profile?.displayName(for: threadVariant)) + .defaulting(to: publicKey), + size: (additionalProfile != nil ? + self.size.multiImageSize : + self.size.viewSize + ) + ).pngData() + ), + icon: profileIcon + ), + additionalInfo: additionalProfile + .map { otherProfile in + Info( + imageData: ( + ProfileManager.profileAvatar(profile: otherProfile) ?? + PlaceholderIcon.generate( + seed: otherProfile.id, + text: otherProfile.displayName(for: threadVariant), + size: self.size.multiImageSize + ).pngData() + ), + icon: additionalProfileIcon + ) + } + .defaulting( + to: Info( + imageData: UIImage(systemName: "person.fill")?.pngData(), + renderingMode: .alwaysTemplate, + themeTintColor: .white, + inset: UIEdgeInsets( + top: 3, + left: 0, + bottom: -5, + right: 0 + ), + icon: additionalProfileIcon + ) + ) + ) + + case .contact: + guard !publicKey.isEmpty else { return } + + update( + Info( + imageData: ( + profile.map { ProfileManager.profileAvatar(profile: $0) } ?? + PlaceholderIcon.generate( + seed: publicKey, + text: (profile?.displayName(for: threadVariant)) + .defaulting(to: publicKey), + size: (additionalProfile != nil ? + self.size.multiImageSize : + self.size.viewSize + ) + ).pngData() + ), + icon: profileIcon + ) + ) + } + } +} diff --git a/SessionMessagingKit/Utilities/Promise+Utilities.swift b/SessionMessagingKit/Utilities/Promise+Utilities.swift deleted file mode 100644 index ed2f23f4b..000000000 --- a/SessionMessagingKit/Utilities/Promise+Utilities.swift +++ /dev/null @@ -1,29 +0,0 @@ -// 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/SSKReachabilityManager.swift b/SessionMessagingKit/Utilities/SSKReachabilityManager.swift index 9f6bea814..a68fa58ba 100644 --- a/SessionMessagingKit/Utilities/SSKReachabilityManager.swift +++ b/SessionMessagingKit/Utilities/SSKReachabilityManager.swift @@ -7,6 +7,8 @@ public enum ReachabilityType: Int { @objc public protocol SSKReachabilityManager { + var reachability: Reachability { get } + var observationContext: AnyObject { get } func setup() diff --git a/SessionMessagingKit/Utilities/Sodium+Utilities.swift b/SessionMessagingKit/Utilities/SessionCrypto.swift similarity index 92% rename from SessionMessagingKit/Utilities/Sodium+Utilities.swift rename to SessionMessagingKit/Utilities/SessionCrypto.swift index 4e113c2e7..e785ef186 100644 --- a/SessionMessagingKit/Utilities/Sodium+Utilities.swift +++ b/SessionMessagingKit/Utilities/SessionCrypto.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import CryptoKit import Clibsodium import Sodium import Curve25519Kit @@ -68,7 +69,7 @@ extension Sodium { } /// 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? { + public func blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair, genericHash: GenericHashType) -> KeyPair? { guard edKeyPair.publicKey.count == Sodium.publicKeyLength && edKeyPair.secretKey.count == Sodium.secretKeyLength else { return nil } @@ -97,7 +98,7 @@ extension Sodium { guard crypto_scalarmult_ed25519_base_noclamp(kAPtr, kaPtr) == 0 else { return nil } - return Box.KeyPair( + return KeyPair( publicKey: Data(bytes: kAPtr, count: Sodium.publicKeyLength).bytes, secretKey: Data(bytes: kaPtr, count: Sodium.secretKeyLength).bytes ) @@ -108,10 +109,10 @@ extension Sodium { /// 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)) + let H_rh: Bytes = Bytes(SHA512.hash(data: secretKey).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 combinedHashBytes: Bytes = SHA512.hash(data: H_rh + kA + message).bytes let rPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) _ = combinedHashBytes.withUnsafeBytes { (combinedHashPtr: UnsafeRawBufferPointer) -> Int32 in @@ -129,7 +130,7 @@ extension Sodium { /// 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 HRAMHashBytes: Bytes = SHA512.hash(data: sig_RBytes + kA + message).bytes let HRAMPtr: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: Sodium.scalarLength) _ = HRAMHashBytes.withUnsafeBytes { (HRAMHashPtr: UnsafeRawBufferPointer) -> Int32 in @@ -202,11 +203,16 @@ extension Sodium { /// 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 - } + guard + let sessionId: SessionId = SessionId(from: standardSessionId), + sessionId.prefix == .standard, + let blindedId: SessionId = SessionId(from: blindedSessionId), + ( + blindedId.prefix == .blinded15 || + blindedId.prefix == .blinded25 + ), + 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) @@ -223,8 +229,8 @@ extension Sodium { 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 + SessionId(.blinded15, publicKey: pk1).publicKey == blindedId.publicKey || + SessionId(.blinded15, publicKey: pk2).publicKey == blindedId.publicKey ) } } @@ -277,12 +283,3 @@ extension AeadXChaCha20Poly1305IetfType { 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/Threading.swift b/SessionMessagingKit/Utilities/Threading.swift index b7e1cab79..ef06b3c6b 100644 --- a/SessionMessagingKit/Utilities/Threading.swift +++ b/SessionMessagingKit/Utilities/Threading.swift @@ -1,6 +1,5 @@ import Foundation -internal enum Threading { - - internal static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue") +public enum Threading { + public static let pollerQueue = DispatchQueue(label: "SessionMessagingKit.pollerQueue") } diff --git a/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift index f828c4394..f8b824165 100644 --- a/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/Types/MessageSendJobSpec.swift @@ -27,9 +27,9 @@ class MessageSendJobSpec: QuickSpec { beforeEach { mockStorage = Storage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations(), - SNMessagingKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self, + SNMessagingKit.self ] ) mockJobRunner = MockJobRunner() @@ -47,21 +47,18 @@ class MessageSendJobSpec: QuickSpec { ) mockStorage.write { db in - try SessionThread.fetchOrCreate(db, id: "Test1", variant: .contact) + try SessionThread.fetchOrCreate(db, id: "Test1", variant: .contact, shouldBeVisible: true) } mockJobRunner .when { - $0.hasJob( - of: any(), - inState: .running, - with: AttachmentUploadJob.Details( - messageSendJobId: 1, - attachmentId: attachment1.id - ) + $0.jobInfoFor( + jobs: nil, + state: .running, + variant: .attachmentUpload ) } - .thenReturn(false) + .thenReturn([:]) mockJobRunner .when { $0.insert(any(), job: any(), before: any(), dependencies: dependencies) } .then { args in @@ -353,16 +350,23 @@ class MessageSendJobSpec: QuickSpec { it("inserts an attachment upload job before the message send job") { mockJobRunner .when { - $0.hasJob( - of: any(), - inState: .running, - with: AttachmentUploadJob.Details( - messageSendJobId: 1, - attachmentId: "200" - ) + $0.jobInfoFor( + jobs: nil, + state: .running, + variant: .attachmentUpload ) } - .thenReturn(false) + .thenReturn([ + 2: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: 100, + detailsData: try! JSONEncoder().encode(AttachmentUploadJob.Details( + messageSendJobId: 1, + attachmentId: "200" + )) + ) + ]) MessageSendJob.run( job, diff --git a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigContactsSpec.swift b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigContactsSpec.swift new file mode 100644 index 000000000..a57839b28 --- /dev/null +++ b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigContactsSpec.swift @@ -0,0 +1,547 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Sodium +import SessionUtil +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches +class ConfigContactsSpec { + enum ContactProperty: CaseIterable { + case name + case nickname + case approved + case approved_me + case blocked + case profile_pic + case created + case notifications + case mute_until + } + + // MARK: - Spec + + static func spec() { + context("CONTACTS") { + // MARK: - when checking error catching + context("when checking error catching") { + var seed: Data! + var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)! + var edSK: [UInt8]! + var error: UnsafeMutablePointer? + var conf: UnsafeMutablePointer? + + beforeEach { + seed = Data(hex: "0123456789abcdef0123456789abcdef") + + // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately + identity = try! Identity.generate(from: seed) + edSK = identity.ed25519KeyPair.secretKey + + // Initialize a brand new, empty config because we have no dump data to deal with. + error = nil + conf = nil + _ = contacts_init(&conf, &edSK, nil, 0, error) + error?.deallocate() + } + + // MARK: -- it can catch size limit errors thrown when pushing + it("can catch size limit errors thrown when pushing") { + var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000) + + try (0..<10000).forEach { index in + var contact: contacts_contact = try createContact( + for: index, + in: conf, + rand: &randomGenerator, + maxing: .allProperties + ) + contacts_set(conf, &contact) + } + + expect(contacts_size(conf)).to(equal(10000)) + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_dump(conf)).to(beTrue()) + + expect { + try CExceptionHelper.performSafely { config_push(conf).deallocate() } + } + .to(throwError(NSError(domain: "cpp_exception", code: -2, userInfo: ["NSLocalizedDescription": "Config data is too large"]))) + } + } + + // MARK: - when checking size limits + context("when checking size limits") { + var numRecords: Int! + var seed: Data! + var identity: (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair)! + var edSK: [UInt8]! + var error: UnsafeMutablePointer? + var conf: UnsafeMutablePointer? + + beforeEach { + numRecords = 0 + seed = Data(hex: "0123456789abcdef0123456789abcdef") + + // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately + identity = try! Identity.generate(from: seed) + edSK = identity.ed25519KeyPair.secretKey + + // Initialize a brand new, empty config because we have no dump data to deal with. + error = nil + conf = nil + _ = contacts_init(&conf, &edSK, nil, 0, error) + error?.deallocate() + } + + // MARK: -- has not changed the max empty records + it("has not changed the max empty records") { + var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000) + + for index in (0..<100000) { + var contact: contacts_contact = try createContact( + for: index, + in: conf, + rand: &randomGenerator + ) + contacts_set(conf, &contact) + + do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } } + catch { break } + + // We successfully inserted a contact and didn't hit the limit so increment the counter + numRecords += 1 + } + + // Check that the record count matches the maximum when we last checked + expect(numRecords).to(equal(2370)) + } + + // MARK: -- has not changed the max name only records + it("has not changed the max name only records") { + var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000) + + for index in (0..<100000) { + var contact: contacts_contact = try createContact( + for: index, + in: conf, + rand: &randomGenerator, + maxing: [.name] + ) + contacts_set(conf, &contact) + + do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } } + catch { break } + + // We successfully inserted a contact and didn't hit the limit so increment the counter + numRecords += 1 + } + + // Check that the record count matches the maximum when we last checked + expect(numRecords).to(equal(796)) + } + + // MARK: -- has not changed the max name and profile pic only records + it("has not changed the max name and profile pic only records") { + var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000) + + for index in (0..<100000) { + var contact: contacts_contact = try createContact( + for: index, + in: conf, + rand: &randomGenerator, + maxing: [.name, .profile_pic] + ) + contacts_set(conf, &contact) + + do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } } + catch { break } + + // We successfully inserted a contact and didn't hit the limit so increment the counter + numRecords += 1 + } + + // Check that the record count matches the maximum when we last checked + expect(numRecords).to(equal(290)) + } + + // MARK: -- has not changed the max filled records + it("has not changed the max filled records") { + var randomGenerator: ARC4RandomNumberGenerator = ARC4RandomNumberGenerator(seed: 1000) + + for index in (0..<100000) { + var contact: contacts_contact = try createContact( + for: index, + in: conf, + rand: &randomGenerator, + maxing: .allProperties + ) + contacts_set(conf, &contact) + + do { try CExceptionHelper.performSafely { config_push(conf).deallocate() } } + catch { break } + + // We successfully inserted a contact and didn't hit the limit so increment the counter + numRecords += 1 + } + + // Check that the record count matches the maximum when we last checked + expect(numRecords).to(equal(236)) + } + } + + // MARK: - generates config correctly + + it("generates config correctly") { + let createdTs: Int64 = 1680064059 + let nowTs: Int64 = Int64(Date().timeIntervalSince1970) + let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") + + // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately + let identity = try! Identity.generate(from: seed) + var edSK: [UInt8] = identity.ed25519KeyPair.secretKey + expect(edSK.toHexString().suffix(64)) + .to(equal("4cb76fdc6d32278e3f83dbf608360ecc6b65727934b85d2fb86862ff98c46ab7")) + expect(identity.x25519KeyPair.publicKey.toHexString()) + .to(equal("d2ad010eeb72d72e561d9de7bd7b6989af77dcabffa03a5111a6c859ae5c3a72")) + expect(String(edSK.toHexString().prefix(32))).to(equal(seed.toHexString())) + + // Initialize a brand new, empty config because we have no dump data to deal with. + let error: UnsafeMutablePointer? = nil + var conf: UnsafeMutablePointer? = nil + expect(contacts_init(&conf, &edSK, nil, 0, error)).to(equal(0)) + error?.deallocate() + + // Empty contacts shouldn't have an existing contact + let definitelyRealId: String = "050000000000000000000000000000000000000000000000000000000000000000" + var cDefinitelyRealId: [CChar] = definitelyRealId.cArray.nullTerminated() + let contactPtr: UnsafeMutablePointer? = nil + expect(contacts_get(conf, contactPtr, &cDefinitelyRealId)).to(beFalse()) + + expect(contacts_size(conf)).to(equal(0)) + + var contact2: contacts_contact = contacts_contact() + expect(contacts_get_or_construct(conf, &contact2, &cDefinitelyRealId)).to(beTrue()) + expect(String(libSessionVal: contact2.name)).to(beEmpty()) + expect(String(libSessionVal: contact2.nickname)).to(beEmpty()) + expect(contact2.approved).to(beFalse()) + expect(contact2.approved_me).to(beFalse()) + expect(contact2.blocked).to(beFalse()) + expect(contact2.profile_pic).toNot(beNil()) // Creates an empty instance apparently + expect(String(libSessionVal: contact2.profile_pic.url)).to(beEmpty()) + expect(contact2.created).to(equal(0)) + expect(contact2.notifications).to(equal(CONVO_NOTIFY_DEFAULT)) + expect(contact2.mute_until).to(equal(0)) + + expect(config_needs_push(conf)).to(beFalse()) + expect(config_needs_dump(conf)).to(beFalse()) + + let pushData1: UnsafeMutablePointer = config_push(conf) + expect(pushData1.pointee.seqno).to(equal(0)) + pushData1.deallocate() + + // Update the contact data + contact2.name = "Joe".toLibSession() + contact2.nickname = "Joey".toLibSession() + contact2.approved = true + contact2.approved_me = true + contact2.created = createdTs + contact2.notifications = CONVO_NOTIFY_ALL + contact2.mute_until = nowTs + 1800 + + // Update the contact + contacts_set(conf, &contact2) + + // Ensure the contact details were updated + var contact3: contacts_contact = contacts_contact() + expect(contacts_get(conf, &contact3, &cDefinitelyRealId)).to(beTrue()) + expect(String(libSessionVal: contact3.name)).to(equal("Joe")) + expect(String(libSessionVal: contact3.nickname)).to(equal("Joey")) + expect(contact3.approved).to(beTrue()) + expect(contact3.approved_me).to(beTrue()) + expect(contact3.profile_pic).toNot(beNil()) // Creates an empty instance apparently + expect(String(libSessionVal: contact3.profile_pic.url)).to(beEmpty()) + expect(contact3.blocked).to(beFalse()) + expect(String(libSessionVal: contact3.session_id)).to(equal(definitelyRealId)) + expect(contact3.created).to(equal(createdTs)) + expect(contact2.notifications).to(equal(CONVO_NOTIFY_ALL)) + expect(contact2.mute_until).to(equal(nowTs + 1800)) + + + // Since we've made changes, we should need to push new config to the swarm, *and* should need + // to dump the updated state: + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_dump(conf)).to(beTrue()) + + // incremented since we made changes (this only increments once between + // dumps; even though we changed multiple fields here). + let pushData2: UnsafeMutablePointer = config_push(conf) + + // incremented since we made changes (this only increments once between + // dumps; even though we changed multiple fields here). + expect(pushData2.pointee.seqno).to(equal(1)) + + // Pretend we uploaded it + let fakeHash1: String = "fakehash1" + var cFakeHash1: [CChar] = fakeHash1.cArray.nullTerminated() + config_confirm_pushed(conf, pushData2.pointee.seqno, &cFakeHash1) + expect(config_needs_push(conf)).to(beFalse()) + expect(config_needs_dump(conf)).to(beTrue()) + pushData2.deallocate() + + // NB: Not going to check encrypted data and decryption here because that's general (not + // specific to contacts) and is covered already in the user profile tests. + var dump1: UnsafeMutablePointer? = nil + var dump1Len: Int = 0 + config_dump(conf, &dump1, &dump1Len) + + let error2: UnsafeMutablePointer? = nil + var conf2: UnsafeMutablePointer? = nil + expect(contacts_init(&conf2, &edSK, dump1, dump1Len, error2)).to(equal(0)) + error2?.deallocate() + dump1?.deallocate() + + expect(config_needs_push(conf2)).to(beFalse()) + expect(config_needs_dump(conf2)).to(beFalse()) + + let pushData3: UnsafeMutablePointer = config_push(conf2) + expect(pushData3.pointee.seqno).to(equal(1)) + pushData3.deallocate() + + // Because we just called dump() above, to load up contacts2 + expect(config_needs_dump(conf)).to(beFalse()) + + // Ensure the contact details were updated + var contact4: contacts_contact = contacts_contact() + expect(contacts_get(conf2, &contact4, &cDefinitelyRealId)).to(beTrue()) + expect(String(libSessionVal: contact4.name)).to(equal("Joe")) + expect(String(libSessionVal: contact4.nickname)).to(equal("Joey")) + expect(contact4.approved).to(beTrue()) + expect(contact4.approved_me).to(beTrue()) + expect(contact4.profile_pic).toNot(beNil()) // Creates an empty instance apparently + expect(String(libSessionVal: contact4.profile_pic.url)).to(beEmpty()) + expect(contact4.blocked).to(beFalse()) + expect(contact4.created).to(equal(createdTs)) + + let anotherId: String = "051111111111111111111111111111111111111111111111111111111111111111" + var cAnotherId: [CChar] = anotherId.cArray.nullTerminated() + var contact5: contacts_contact = contacts_contact() + expect(contacts_get_or_construct(conf2, &contact5, &cAnotherId)).to(beTrue()) + expect(String(libSessionVal: contact5.name)).to(beEmpty()) + expect(String(libSessionVal: contact5.nickname)).to(beEmpty()) + expect(contact5.approved).to(beFalse()) + expect(contact5.approved_me).to(beFalse()) + expect(contact5.profile_pic).toNot(beNil()) // Creates an empty instance apparently + expect(String(libSessionVal: contact5.profile_pic.url)).to(beEmpty()) + expect(contact5.blocked).to(beFalse()) + + // We're not setting any fields, but we should still keep a record of the session id + contacts_set(conf2, &contact5) + expect(config_needs_push(conf2)).to(beTrue()) + + let pushData4: UnsafeMutablePointer = config_push(conf2) + expect(pushData4.pointee.seqno).to(equal(2)) + + // Check the merging + let fakeHash2: String = "fakehash2" + var cFakeHash2: [CChar] = fakeHash2.cArray.nullTerminated() + var mergeHashes: [UnsafePointer?] = [cFakeHash2].unsafeCopy() + var mergeData: [UnsafePointer?] = [UnsafePointer(pushData4.pointee.config)] + var mergeSize: [Int] = [pushData4.pointee.config_len] + expect(config_merge(conf, &mergeHashes, &mergeData, &mergeSize, 1)).to(equal(1)) + config_confirm_pushed(conf2, pushData4.pointee.seqno, &cFakeHash2) + mergeHashes.forEach { $0?.deallocate() } + pushData4.deallocate() + + expect(config_needs_push(conf)).to(beFalse()) + + let pushData5: UnsafeMutablePointer = config_push(conf) + expect(pushData5.pointee.seqno).to(equal(2)) + pushData5.deallocate() + + // Iterate through and make sure we got everything we expected + var sessionIds: [String] = [] + var nicknames: [String] = [] + expect(contacts_size(conf)).to(equal(2)) + + var contact6: contacts_contact = contacts_contact() + let contactIterator: UnsafeMutablePointer = contacts_iterator_new(conf) + while !contacts_iterator_done(contactIterator, &contact6) { + sessionIds.append(String(libSessionVal: contact6.session_id)) + nicknames.append(String(libSessionVal: contact6.nickname, nullIfEmpty: true) ?? "(N/A)") + contacts_iterator_advance(contactIterator) + } + contacts_iterator_free(contactIterator) // Need to free the iterator + + expect(sessionIds.count).to(equal(2)) + expect(sessionIds.count).to(equal(contacts_size(conf))) + expect(sessionIds.first).to(equal(definitelyRealId)) + expect(sessionIds.last).to(equal(anotherId)) + expect(nicknames.first).to(equal("Joey")) + expect(nicknames.last).to(equal("(N/A)")) + + // Conflict! Oh no! + + // On client 1 delete a contact: + contacts_erase(conf, definitelyRealId) + + // Client 2 adds a new friend: + let thirdId: String = "052222222222222222222222222222222222222222222222222222222222222222" + var cThirdId: [CChar] = thirdId.cArray.nullTerminated() + var contact7: contacts_contact = contacts_contact() + expect(contacts_get_or_construct(conf2, &contact7, &cThirdId)).to(beTrue()) + contact7.nickname = "Nickname 3".toLibSession() + contact7.approved = true + contact7.approved_me = true + contact7.profile_pic.url = "http://example.com/huge.bmp".toLibSession() + contact7.profile_pic.key = "qwerty78901234567890123456789012".data(using: .utf8)!.toLibSession() + contacts_set(conf2, &contact7) + + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_push(conf2)).to(beTrue()) + + let pushData6: UnsafeMutablePointer = config_push(conf) + expect(pushData6.pointee.seqno).to(equal(3)) + + let pushData7: UnsafeMutablePointer = config_push(conf2) + expect(pushData7.pointee.seqno).to(equal(3)) + + let pushData6Str: String = String(pointer: pushData6.pointee.config, length: pushData6.pointee.config_len, encoding: .ascii)! + let pushData7Str: String = String(pointer: pushData7.pointee.config, length: pushData7.pointee.config_len, encoding: .ascii)! + expect(pushData6Str).toNot(equal(pushData7Str)) + expect([String](pointer: pushData6.pointee.obsolete, count: pushData6.pointee.obsolete_len)) + .to(equal([fakeHash2])) + expect([String](pointer: pushData7.pointee.obsolete, count: pushData7.pointee.obsolete_len)) + .to(equal([fakeHash2])) + + let fakeHash3a: String = "fakehash3a" + var cFakeHash3a: [CChar] = fakeHash3a.cArray.nullTerminated() + let fakeHash3b: String = "fakehash3b" + var cFakeHash3b: [CChar] = fakeHash3b.cArray.nullTerminated() + config_confirm_pushed(conf, pushData6.pointee.seqno, &cFakeHash3a) + config_confirm_pushed(conf2, pushData7.pointee.seqno, &cFakeHash3b) + + var mergeHashes2: [UnsafePointer?] = [cFakeHash3b].unsafeCopy() + var mergeData2: [UnsafePointer?] = [UnsafePointer(pushData7.pointee.config)] + var mergeSize2: [Int] = [pushData7.pointee.config_len] + expect(config_merge(conf, &mergeHashes2, &mergeData2, &mergeSize2, 1)).to(equal(1)) + expect(config_needs_push(conf)).to(beTrue()) + + var mergeHashes3: [UnsafePointer?] = [cFakeHash3a].unsafeCopy() + var mergeData3: [UnsafePointer?] = [UnsafePointer(pushData6.pointee.config)] + var mergeSize3: [Int] = [pushData6.pointee.config_len] + expect(config_merge(conf2, &mergeHashes3, &mergeData3, &mergeSize3, 1)).to(equal(1)) + expect(config_needs_push(conf2)).to(beTrue()) + mergeHashes2.forEach { $0?.deallocate() } + mergeHashes3.forEach { $0?.deallocate() } + pushData6.deallocate() + pushData7.deallocate() + + let pushData8: UnsafeMutablePointer = config_push(conf) + expect(pushData8.pointee.seqno).to(equal(4)) + + let pushData9: UnsafeMutablePointer = config_push(conf2) + expect(pushData9.pointee.seqno).to(equal(pushData8.pointee.seqno)) + + let pushData8Str: String = String(pointer: pushData8.pointee.config, length: pushData8.pointee.config_len, encoding: .ascii)! + let pushData9Str: String = String(pointer: pushData9.pointee.config, length: pushData9.pointee.config_len, encoding: .ascii)! + expect(pushData8Str).to(equal(pushData9Str)) + expect([String](pointer: pushData8.pointee.obsolete, count: pushData8.pointee.obsolete_len)) + .to(equal([fakeHash3b, fakeHash3a])) + expect([String](pointer: pushData9.pointee.obsolete, count: pushData9.pointee.obsolete_len)) + .to(equal([fakeHash3a, fakeHash3b])) + + let fakeHash4: String = "fakeHash4" + var cFakeHash4: [CChar] = fakeHash4.cArray.nullTerminated() + config_confirm_pushed(conf, pushData8.pointee.seqno, &cFakeHash4) + config_confirm_pushed(conf2, pushData9.pointee.seqno, &cFakeHash4) + pushData8.deallocate() + pushData9.deallocate() + + expect(config_needs_push(conf)).to(beFalse()) + expect(config_needs_push(conf2)).to(beFalse()) + + // Validate the changes + var sessionIds2: [String] = [] + var nicknames2: [String] = [] + expect(contacts_size(conf)).to(equal(2)) + + var contact8: contacts_contact = contacts_contact() + let contactIterator2: UnsafeMutablePointer = contacts_iterator_new(conf) + while !contacts_iterator_done(contactIterator2, &contact8) { + sessionIds2.append(String(libSessionVal: contact8.session_id)) + nicknames2.append(String(libSessionVal: contact8.nickname, nullIfEmpty: true) ?? "(N/A)") + contacts_iterator_advance(contactIterator2) + } + contacts_iterator_free(contactIterator2) // Need to free the iterator + + expect(sessionIds2.count).to(equal(2)) + expect(sessionIds2.first).to(equal(anotherId)) + expect(sessionIds2.last).to(equal(thirdId)) + expect(nicknames2.first).to(equal("(N/A)")) + expect(nicknames2.last).to(equal("Nickname 3")) + } + } + } + + // MARK: - Convenience + + private static func createContact( + for index: Int, + in conf: UnsafeMutablePointer?, + rand: inout ARC4RandomNumberGenerator, + maxing properties: [ContactProperty] = [] + ) throws -> contacts_contact { + let postPrefixId: String = "05\(rand.nextBytes(count: 32).toHexString())" + let sessionId: String = ("05\(index)a" + postPrefixId.suffix(postPrefixId.count - "05\(index)a".count)) + var cSessionId: [CChar] = sessionId.cArray.nullTerminated() + var contact: contacts_contact = contacts_contact() + + guard contacts_get_or_construct(conf, &contact, &cSessionId) else { + throw SessionUtilError.getOrConstructFailedUnexpectedly + } + + // Set the values to the maximum data that can fit + properties.forEach { property in + switch property { + case .approved: contact.approved = true + case .approved_me: contact.approved_me = true + case .blocked: contact.blocked = true + case .created: contact.created = Int64.max + case .notifications: contact.notifications = CONVO_NOTIFY_MENTIONS_ONLY + case .mute_until: contact.mute_until = Int64.max + + case .name: + contact.name = rand.nextBytes(count: SessionUtil.libSessionMaxNameByteLength) + .toHexString() + .toLibSession() + + case .nickname: + contact.nickname = rand.nextBytes(count: SessionUtil.libSessionMaxNameByteLength) + .toHexString() + .toLibSession() + + case .profile_pic: + contact.profile_pic = user_profile_pic( + url: rand.nextBytes(count: SessionUtil.libSessionMaxProfileUrlByteLength) + .toHexString() + .toLibSession(), + key: Data(rand.nextBytes(count: 32)) + .toLibSession() + ) + } + } + + return contact + } +} + +fileprivate extension Array where Element == ConfigContactsSpec.ContactProperty { + static var allProperties: [ConfigContactsSpec.ContactProperty] = ConfigContactsSpec.ContactProperty.allCases +} diff --git a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigConvoInfoVolatileSpec.swift b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigConvoInfoVolatileSpec.swift new file mode 100644 index 000000000..86325c4ad --- /dev/null +++ b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigConvoInfoVolatileSpec.swift @@ -0,0 +1,267 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtil +import SessionUtilitiesKit + +import Quick +import Nimble + +/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches +class ConfigConvoInfoVolatileSpec { + // MARK: - Spec + + static func spec() { + context("CONVO_INFO_VOLATILE") { + it("generates config correctly") { + let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") + + // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately + let identity = try! Identity.generate(from: seed) + var edSK: [UInt8] = identity.ed25519KeyPair.secretKey + expect(edSK.toHexString().suffix(64)) + .to(equal("4cb76fdc6d32278e3f83dbf608360ecc6b65727934b85d2fb86862ff98c46ab7")) + expect(identity.x25519KeyPair.publicKey.toHexString()) + .to(equal("d2ad010eeb72d72e561d9de7bd7b6989af77dcabffa03a5111a6c859ae5c3a72")) + expect(String(edSK.toHexString().prefix(32))).to(equal(seed.toHexString())) + + // Initialize a brand new, empty config because we have no dump data to deal with. + let error: UnsafeMutablePointer? = nil + var conf: UnsafeMutablePointer? = nil + expect(convo_info_volatile_init(&conf, &edSK, nil, 0, error)).to(equal(0)) + error?.deallocate() + + // Empty contacts shouldn't have an existing contact + let definitelyRealId: String = "055000000000000000000000000000000000000000000000000000000000000000" + var cDefinitelyRealId: [CChar] = definitelyRealId.cArray.nullTerminated() + var oneToOne1: convo_info_volatile_1to1 = convo_info_volatile_1to1() + expect(convo_info_volatile_get_1to1(conf, &oneToOne1, &cDefinitelyRealId)).to(beFalse()) + expect(convo_info_volatile_size(conf)).to(equal(0)) + + var oneToOne2: convo_info_volatile_1to1 = convo_info_volatile_1to1() + expect(convo_info_volatile_get_or_construct_1to1(conf, &oneToOne2, &cDefinitelyRealId)) + .to(beTrue()) + expect(String(libSessionVal: oneToOne2.session_id)).to(equal(definitelyRealId)) + expect(oneToOne2.last_read).to(equal(0)) + expect(oneToOne2.unread).to(beFalse()) + + // No need to sync a conversation with a default state + expect(config_needs_push(conf)).to(beFalse()) + expect(config_needs_dump(conf)).to(beFalse()) + + // Update the last read + let nowTimestampMs: Int64 = Int64(floor(Date().timeIntervalSince1970 * 1000)) + oneToOne2.last_read = nowTimestampMs + + // The new data doesn't get stored until we call this: + convo_info_volatile_set_1to1(conf, &oneToOne2) + + var legacyGroup1: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + var oneToOne3: convo_info_volatile_1to1 = convo_info_volatile_1to1() + expect(convo_info_volatile_get_legacy_group(conf, &legacyGroup1, &cDefinitelyRealId)) + .to(beFalse()) + expect(convo_info_volatile_get_1to1(conf, &oneToOne3, &cDefinitelyRealId)).to(beTrue()) + expect(oneToOne3.last_read).to(equal(nowTimestampMs)) + + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_dump(conf)).to(beTrue()) + + let openGroupBaseUrl: String = "http://Example.ORG:5678" + var cOpenGroupBaseUrl: [CChar] = openGroupBaseUrl.cArray.nullTerminated() + let openGroupBaseUrlResult: String = openGroupBaseUrl.lowercased() + // ("http://Example.ORG:5678" + // .lowercased() + // .cArray + + // [CChar](repeating: 0, count: (268 - openGroupBaseUrl.count)) + // ) + let openGroupRoom: String = "SudokuRoom" + var cOpenGroupRoom: [CChar] = openGroupRoom.cArray.nullTerminated() + let openGroupRoomResult: String = openGroupRoom.lowercased() + // ("SudokuRoom" + // .lowercased() + // .cArray + + // [CChar](repeating: 0, count: (65 - openGroupRoom.count)) + // ) + var cOpenGroupPubkey: [UInt8] = Data(hex: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + .bytes + var community1: convo_info_volatile_community = convo_info_volatile_community() + expect(convo_info_volatile_get_or_construct_community(conf, &community1, &cOpenGroupBaseUrl, &cOpenGroupRoom, &cOpenGroupPubkey)).to(beTrue()) + expect(String(libSessionVal: community1.base_url)).to(equal(openGroupBaseUrlResult)) + expect(String(libSessionVal: community1.room)).to(equal(openGroupRoomResult)) + expect(Data(libSessionVal: community1.pubkey, count: 32).toHexString()) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + community1.unread = true + + // The new data doesn't get stored until we call this: + convo_info_volatile_set_community(conf, &community1); + + // We don't need to push since we haven't changed anything, so this call is mainly just for + // testing: + let pushData1: UnsafeMutablePointer = config_push(conf) + expect(pushData1.pointee.seqno).to(equal(1)) + + // Pretend we uploaded it + let fakeHash1: String = "fakehash1" + var cFakeHash1: [CChar] = fakeHash1.cArray.nullTerminated() + config_confirm_pushed(conf, pushData1.pointee.seqno, &cFakeHash1) + expect(config_needs_dump(conf)).to(beTrue()) + expect(config_needs_push(conf)).to(beFalse()) + pushData1.deallocate() + + var dump1: UnsafeMutablePointer? = nil + var dump1Len: Int = 0 + config_dump(conf, &dump1, &dump1Len) + + let error2: UnsafeMutablePointer? = nil + var conf2: UnsafeMutablePointer? = nil + expect(convo_info_volatile_init(&conf2, &edSK, dump1, dump1Len, error2)).to(equal(0)) + error2?.deallocate() + dump1?.deallocate() + + expect(config_needs_dump(conf2)).to(beFalse()) + expect(config_needs_push(conf2)).to(beFalse()) + + var oneToOne4: convo_info_volatile_1to1 = convo_info_volatile_1to1() + expect(convo_info_volatile_get_1to1(conf2, &oneToOne4, &cDefinitelyRealId)).to(equal(true)) + expect(oneToOne4.last_read).to(equal(nowTimestampMs)) + expect(String(libSessionVal: oneToOne4.session_id)).to(equal(definitelyRealId)) + expect(oneToOne4.unread).to(beFalse()) + + var community2: convo_info_volatile_community = convo_info_volatile_community() + expect(convo_info_volatile_get_community(conf2, &community2, &cOpenGroupBaseUrl, &cOpenGroupRoom)).to(beTrue()) + expect(String(libSessionVal: community2.base_url)).to(equal(openGroupBaseUrlResult)) + expect(String(libSessionVal: community2.room)).to(equal(openGroupRoomResult)) + expect(Data(libSessionVal: community2.pubkey, count: 32).toHexString()) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + community2.unread = true + + let anotherId: String = "051111111111111111111111111111111111111111111111111111111111111111" + var cAnotherId: [CChar] = anotherId.cArray.nullTerminated() + var oneToOne5: convo_info_volatile_1to1 = convo_info_volatile_1to1() + expect(convo_info_volatile_get_or_construct_1to1(conf2, &oneToOne5, &cAnotherId)).to(beTrue()) + oneToOne5.unread = true + convo_info_volatile_set_1to1(conf2, &oneToOne5) + + let thirdId: String = "05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + var cThirdId: [CChar] = thirdId.cArray.nullTerminated() + var legacyGroup2: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + expect(convo_info_volatile_get_or_construct_legacy_group(conf2, &legacyGroup2, &cThirdId)).to(beTrue()) + legacyGroup2.last_read = (nowTimestampMs - 50) + convo_info_volatile_set_legacy_group(conf2, &legacyGroup2) + expect(config_needs_push(conf2)).to(beTrue()) + + let pushData2: UnsafeMutablePointer = config_push(conf2) + expect(pushData2.pointee.seqno).to(equal(2)) + + // Check the merging + let fakeHash2: String = "fakehash2" + var cFakeHash2: [CChar] = fakeHash2.cArray.nullTerminated() + var mergeHashes: [UnsafePointer?] = [cFakeHash2].unsafeCopy() + var mergeData: [UnsafePointer?] = [UnsafePointer(pushData2.pointee.config)] + var mergeSize: [Int] = [pushData2.pointee.config_len] + expect(config_merge(conf, &mergeHashes, &mergeData, &mergeSize, 1)).to(equal(1)) + config_confirm_pushed(conf, pushData2.pointee.seqno, &cFakeHash2) + pushData2.deallocate() + + expect(config_needs_push(conf)).to(beFalse()) + + for targetConf in [conf, conf2] { + // Iterate through and make sure we got everything we expected + var seen: [String] = [] + expect(convo_info_volatile_size(conf)).to(equal(4)) + expect(convo_info_volatile_size_1to1(conf)).to(equal(2)) + expect(convo_info_volatile_size_communities(conf)).to(equal(1)) + expect(convo_info_volatile_size_legacy_groups(conf)).to(equal(1)) + + var c1: convo_info_volatile_1to1 = convo_info_volatile_1to1() + var c2: convo_info_volatile_community = convo_info_volatile_community() + var c3: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + let it: OpaquePointer = convo_info_volatile_iterator_new(targetConf) + + while !convo_info_volatile_iterator_done(it) { + if convo_info_volatile_it_is_1to1(it, &c1) { + seen.append("1-to-1: \(String(libSessionVal: c1.session_id))") + } + else if convo_info_volatile_it_is_community(it, &c2) { + seen.append("og: \(String(libSessionVal: c2.base_url))/r/\(String(libSessionVal: c2.room))") + } + else if convo_info_volatile_it_is_legacy_group(it, &c3) { + seen.append("cl: \(String(libSessionVal: c3.group_id))") + } + + convo_info_volatile_iterator_advance(it) + } + + convo_info_volatile_iterator_free(it) + + expect(seen).to(equal([ + "1-to-1: 051111111111111111111111111111111111111111111111111111111111111111", + "1-to-1: 055000000000000000000000000000000000000000000000000000000000000000", + "og: http://example.org:5678/r/sudokuroom", + "cl: 05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ])) + } + + let fourthId: String = "052000000000000000000000000000000000000000000000000000000000000000" + var cFourthId: [CChar] = fourthId.cArray.nullTerminated() + expect(config_needs_push(conf)).to(beFalse()) + convo_info_volatile_erase_1to1(conf, &cFourthId) + expect(config_needs_push(conf)).to(beFalse()) + convo_info_volatile_erase_1to1(conf, &cDefinitelyRealId) + expect(config_needs_push(conf)).to(beTrue()) + expect(convo_info_volatile_size(conf)).to(equal(3)) + expect(convo_info_volatile_size_1to1(conf)).to(equal(1)) + + // Check the single-type iterators: + var seen1: [String?] = [] + var c1: convo_info_volatile_1to1 = convo_info_volatile_1to1() + let it1: OpaquePointer = convo_info_volatile_iterator_new_1to1(conf) + + while !convo_info_volatile_iterator_done(it1) { + expect(convo_info_volatile_it_is_1to1(it1, &c1)).to(beTrue()) + + seen1.append(String(libSessionVal: c1.session_id)) + convo_info_volatile_iterator_advance(it1) + } + + convo_info_volatile_iterator_free(it1) + expect(seen1).to(equal([ + "051111111111111111111111111111111111111111111111111111111111111111" + ])) + + var seen2: [String?] = [] + var c2: convo_info_volatile_community = convo_info_volatile_community() + let it2: OpaquePointer = convo_info_volatile_iterator_new_communities(conf) + + while !convo_info_volatile_iterator_done(it2) { + expect(convo_info_volatile_it_is_community(it2, &c2)).to(beTrue()) + + seen2.append(String(libSessionVal: c2.base_url)) + convo_info_volatile_iterator_advance(it2) + } + + convo_info_volatile_iterator_free(it2) + expect(seen2).to(equal([ + "http://example.org:5678" + ])) + + var seen3: [String?] = [] + var c3: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() + let it3: OpaquePointer = convo_info_volatile_iterator_new_legacy_groups(conf) + + while !convo_info_volatile_iterator_done(it3) { + expect(convo_info_volatile_it_is_legacy_group(it3, &c3)).to(beTrue()) + + seen3.append(String(libSessionVal: c3.group_id)) + convo_info_volatile_iterator_advance(it3) + } + + convo_info_volatile_iterator_free(it3) + expect(seen3).to(equal([ + "05cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ])) + } + } + } +} diff --git a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserGroupsSpec.swift b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserGroupsSpec.swift new file mode 100644 index 000000000..926cf74f6 --- /dev/null +++ b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserGroupsSpec.swift @@ -0,0 +1,589 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtil +import SessionUtilitiesKit +import SessionMessagingKit + +import Quick +import Nimble + +/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches +class ConfigUserGroupsSpec { + // MARK: - Spec + + static func spec() { + it("parses community URLs correctly") { + let result1 = SessionUtil.parseCommunity(url: [ + "https://example.com/", + "SomeRoom?public_key=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ].joined()) + let result2 = SessionUtil.parseCommunity(url: [ + "HTTPS://EXAMPLE.COM/", + "sOMErOOM?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + ].joined()) + let result3 = SessionUtil.parseCommunity(url: [ + "HTTPS://EXAMPLE.COM/r/", + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + ].joined()) + let result4 = SessionUtil.parseCommunity(url: [ + "http://example.com/r/", + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + ].joined()) + let result5 = SessionUtil.parseCommunity(url: [ + "HTTPS://EXAMPLE.com:443/r/", + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + ].joined()) + let result6 = SessionUtil.parseCommunity(url: [ + "HTTP://EXAMPLE.com:80/r/", + "someroom?public_key=0123456789aBcdEF0123456789abCDEF0123456789ABCdef0123456789ABCDEF" + ].joined()) + let result7 = SessionUtil.parseCommunity(url: [ + "http://example.com:80/r/", + "someroom?public_key=ASNFZ4mrze8BI0VniavN7wEjRWeJq83vASNFZ4mrze8" + ].joined()) + let result8 = SessionUtil.parseCommunity(url: [ + "http://example.com:80/r/", + "someroom?public_key=yrtwk3hjixg66yjdeiuauk6p7hy1gtm8tgih55abrpnsxnpm3zzo" + ].joined()) + + expect(result1?.server).to(equal("https://example.com")) + expect(result1?.server).to(equal(result2?.server)) + expect(result1?.server).to(equal(result3?.server)) + expect(result1?.server).toNot(equal(result4?.server)) + expect(result4?.server).to(equal("http://example.com")) + expect(result1?.server).to(equal(result5?.server)) + expect(result4?.server).to(equal(result6?.server)) + expect(result4?.server).to(equal(result7?.server)) + expect(result4?.server).to(equal(result8?.server)) + expect(result1?.room).to(equal("SomeRoom")) + expect(result2?.room).to(equal("sOMErOOM")) + expect(result3?.room).to(equal("someroom")) + expect(result4?.room).to(equal("someroom")) + expect(result5?.room).to(equal("someroom")) + expect(result6?.room).to(equal("someroom")) + expect(result7?.room).to(equal("someroom")) + expect(result8?.room).to(equal("someroom")) + expect(result1?.publicKey) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + expect(result2?.publicKey) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + expect(result3?.publicKey) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + expect(result4?.publicKey) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + expect(result5?.publicKey) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + expect(result6?.publicKey) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + expect(result7?.publicKey) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + expect(result8?.publicKey) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + } + + context("USER_GROUPS") { + it("generates config correctly") { + let createdTs: Int64 = 1680064059 + let nowTs: Int64 = Int64(Date().timeIntervalSince1970) + let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") + + // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately + let identity = try! Identity.generate(from: seed) + var edSK: [UInt8] = identity.ed25519KeyPair.secretKey + expect(edSK.toHexString().suffix(64)) + .to(equal("4cb76fdc6d32278e3f83dbf608360ecc6b65727934b85d2fb86862ff98c46ab7")) + expect(identity.x25519KeyPair.publicKey.toHexString()) + .to(equal("d2ad010eeb72d72e561d9de7bd7b6989af77dcabffa03a5111a6c859ae5c3a72")) + expect(String(edSK.toHexString().prefix(32))).to(equal(seed.toHexString())) + + // Initialize a brand new, empty config because we have no dump data to deal with. + let error: UnsafeMutablePointer? = nil + var conf: UnsafeMutablePointer? = nil + expect(user_groups_init(&conf, &edSK, nil, 0, error)).to(equal(0)) + error?.deallocate() + + // Empty contacts shouldn't have an existing contact + let definitelyRealId: String = "055000000000000000000000000000000000000000000000000000000000000000" + var cDefinitelyRealId: [CChar] = definitelyRealId.cArray.nullTerminated() + let legacyGroup1: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cDefinitelyRealId) + expect(legacyGroup1?.pointee).to(beNil()) + expect(user_groups_size(conf)).to(equal(0)) + + let legacyGroup2: UnsafeMutablePointer = user_groups_get_or_construct_legacy_group(conf, &cDefinitelyRealId) + expect(legacyGroup2.pointee).toNot(beNil()) + expect(String(libSessionVal: legacyGroup2.pointee.session_id)) + .to(equal(definitelyRealId)) + expect(legacyGroup2.pointee.disappearing_timer).to(equal(0)) + expect(String(libSessionVal: legacyGroup2.pointee.enc_pubkey, fixedLength: 32)).to(equal("")) + expect(String(libSessionVal: legacyGroup2.pointee.enc_seckey, fixedLength: 32)).to(equal("")) + expect(legacyGroup2.pointee.priority).to(equal(0)) + expect(String(libSessionVal: legacyGroup2.pointee.name)).to(equal("")) + expect(legacyGroup2.pointee.joined_at).to(equal(0)) + expect(legacyGroup2.pointee.notifications).to(equal(CONVO_NOTIFY_DEFAULT)) + expect(legacyGroup2.pointee.mute_until).to(equal(0)) + + // Iterate through and make sure we got everything we expected + var membersSeen1: [String: Bool] = [:] + var memberSessionId1: UnsafePointer? = nil + var memberAdmin1: Bool = false + let membersIt1: OpaquePointer = ugroups_legacy_members_begin(legacyGroup2) + + while ugroups_legacy_members_next(membersIt1, &memberSessionId1, &memberAdmin1) { + membersSeen1[String(cString: memberSessionId1!)] = memberAdmin1 + } + + ugroups_legacy_members_free(membersIt1) + + expect(membersSeen1).to(beEmpty()) + + // No need to sync a conversation with a default state + expect(config_needs_push(conf)).to(beFalse()) + expect(config_needs_dump(conf)).to(beFalse()) + + // We don't need to push since we haven't changed anything, so this call is mainly just for + // testing: + let pushData1: UnsafeMutablePointer = config_push(conf) + expect(pushData1.pointee.seqno).to(equal(0)) + expect([String](pointer: pushData1.pointee.obsolete, count: pushData1.pointee.obsolete_len)) + .to(beEmpty()) + expect(pushData1.pointee.config_len).to(equal(256)) + pushData1.deallocate() + + let users: [String] = [ + "050000000000000000000000000000000000000000000000000000000000000000", + "051111111111111111111111111111111111111111111111111111111111111111", + "052222222222222222222222222222222222222222222222222222222222222222", + "053333333333333333333333333333333333333333333333333333333333333333", + "054444444444444444444444444444444444444444444444444444444444444444", + "055555555555555555555555555555555555555555555555555555555555555555", + "056666666666666666666666666666666666666666666666666666666666666666" + ] + var cUsers: [[CChar]] = users.map { $0.cArray.nullTerminated() } + legacyGroup2.pointee.name = "Englishmen".toLibSession() + legacyGroup2.pointee.disappearing_timer = 60 + legacyGroup2.pointee.joined_at = createdTs + legacyGroup2.pointee.notifications = CONVO_NOTIFY_ALL + legacyGroup2.pointee.mute_until = (nowTs + 3600) + expect(ugroups_legacy_member_add(legacyGroup2, &cUsers[0], false)).to(beTrue()) + expect(ugroups_legacy_member_add(legacyGroup2, &cUsers[1], true)).to(beTrue()) + expect(ugroups_legacy_member_add(legacyGroup2, &cUsers[2], false)).to(beTrue()) + expect(ugroups_legacy_member_add(legacyGroup2, &cUsers[4], true)).to(beTrue()) + expect(ugroups_legacy_member_add(legacyGroup2, &cUsers[5], false)).to(beTrue()) + expect(ugroups_legacy_member_add(legacyGroup2, &cUsers[2], false)).to(beFalse()) + + // Flip to and from admin + expect(ugroups_legacy_member_add(legacyGroup2, &cUsers[2], true)).to(beTrue()) + expect(ugroups_legacy_member_add(legacyGroup2, &cUsers[1], false)).to(beTrue()) + + expect(ugroups_legacy_member_remove(legacyGroup2, &cUsers[5])).to(beTrue()) + expect(ugroups_legacy_member_remove(legacyGroup2, &cUsers[4])).to(beTrue()) + + var membersSeen2: [String: Bool] = [:] + var memberSessionId2: UnsafePointer? = nil + var memberAdmin2: Bool = false + let membersIt2: OpaquePointer = ugroups_legacy_members_begin(legacyGroup2) + + while ugroups_legacy_members_next(membersIt2, &memberSessionId2, &memberAdmin2) { + membersSeen2[String(cString: memberSessionId2!)] = memberAdmin2 + } + + ugroups_legacy_members_free(membersIt2) + + expect(membersSeen2).to(equal([ + "050000000000000000000000000000000000000000000000000000000000000000": false, + "051111111111111111111111111111111111111111111111111111111111111111": false, + "052222222222222222222222222222222222222222222222222222222222222222": true + ])) + + // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately + let groupSeed: Data = Data(hex: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") + let groupEd25519KeyPair = Sodium().sign.keyPair(seed: groupSeed.bytes)! + let groupX25519PublicKey = Sodium().sign.toX25519(ed25519PublicKey: groupEd25519KeyPair.publicKey)! + + // Note: this isn't exactly what Session actually does here for legacy closed + // groups (rather it uses X25519 keys) but for this test the distinction doesn't matter. + legacyGroup2.pointee.enc_pubkey = Data(groupX25519PublicKey).toLibSession() + legacyGroup2.pointee.enc_seckey = Data(groupEd25519KeyPair.secretKey).toLibSession() + legacyGroup2.pointee.priority = 3 + + expect(Data(libSessionVal: legacyGroup2.pointee.enc_pubkey, count: 32).toHexString()) + .to(equal("c5ba413c336f2fe1fb9a2c525f8a86a412a1db128a7841b4e0e217fa9eb7fd5e")) + expect(Data(libSessionVal: legacyGroup2.pointee.enc_seckey, count: 32).toHexString()) + .to(equal("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")) + + // The new data doesn't get stored until we call this: + user_groups_set_free_legacy_group(conf, legacyGroup2) + + let legacyGroup3: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cDefinitelyRealId) + expect(legacyGroup3?.pointee).toNot(beNil()) + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_dump(conf)).to(beTrue()) + ugroups_legacy_group_free(legacyGroup3) + + let communityPubkey: String = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + var cCommunityPubkey: [UInt8] = Data(hex: communityPubkey).cArray + var cCommunityBaseUrl: [CChar] = "http://Example.ORG:5678".cArray.nullTerminated() + var cCommunityRoom: [CChar] = "SudokuRoom".cArray.nullTerminated() + var community1: ugroups_community_info = ugroups_community_info() + expect(user_groups_get_or_construct_community(conf, &community1, &cCommunityBaseUrl, &cCommunityRoom, &cCommunityPubkey)) + .to(beTrue()) + + expect(String(libSessionVal: community1.base_url)).to(equal("http://example.org:5678")) // Note: lower-case + expect(String(libSessionVal: community1.room)).to(equal("SudokuRoom")) // Note: case-preserving + expect(Data(libSessionVal: community1.pubkey, count: 32).toHexString()) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + community1.priority = 14 + + // The new data doesn't get stored until we call this: + user_groups_set_community(conf, &community1) + + // incremented since we made changes (this only increments once between + // dumps; even though we changed two fields here). + let pushData2: UnsafeMutablePointer = config_push(conf) + expect(pushData2.pointee.seqno).to(equal(1)) + expect([String](pointer: pushData2.pointee.obsolete, count: pushData2.pointee.obsolete_len)) + .to(beEmpty()) + + // Pretend we uploaded it + let fakeHash1: String = "fakehash1" + var cFakeHash1: [CChar] = fakeHash1.cArray.nullTerminated() + config_confirm_pushed(conf, pushData2.pointee.seqno, &cFakeHash1) + expect(config_needs_dump(conf)).to(beTrue()) + expect(config_needs_push(conf)).to(beFalse()) + + var dump1: UnsafeMutablePointer? = nil + var dump1Len: Int = 0 + config_dump(conf, &dump1, &dump1Len) + + let error2: UnsafeMutablePointer? = nil + var conf2: UnsafeMutablePointer? = nil + expect(user_groups_init(&conf2, &edSK, dump1, dump1Len, error2)).to(equal(0)) + error2?.deallocate() + dump1?.deallocate() + + expect(config_needs_dump(conf)).to(beFalse()) // Because we just called dump() above, to load up conf2 + expect(config_needs_push(conf)).to(beFalse()) + + let pushData3: UnsafeMutablePointer = config_push(conf) + expect(pushData3.pointee.seqno).to(equal(1)) + expect([String](pointer: pushData3.pointee.obsolete, count: pushData3.pointee.obsolete_len)) + .to(beEmpty()) + pushData3.deallocate() + + let currentHashes1: UnsafeMutablePointer? = config_current_hashes(conf) + expect([String](pointer: currentHashes1?.pointee.value, count: currentHashes1?.pointee.len)) + .to(equal(["fakehash1"])) + currentHashes1?.deallocate() + + expect(config_needs_push(conf2)).to(beFalse()) + expect(config_needs_dump(conf2)).to(beFalse()) + + let pushData4: UnsafeMutablePointer = config_push(conf2) + expect(pushData4.pointee.seqno).to(equal(1)) + expect(config_needs_dump(conf2)).to(beFalse()) + expect([String](pointer: pushData4.pointee.obsolete, count: pushData4.pointee.obsolete_len)) + .to(beEmpty()) + pushData4.deallocate() + + let currentHashes2: UnsafeMutablePointer? = config_current_hashes(conf2) + expect([String](pointer: currentHashes2?.pointee.value, count: currentHashes2?.pointee.len)) + .to(equal(["fakehash1"])) + currentHashes2?.deallocate() + + expect(user_groups_size(conf2)).to(equal(2)) + expect(user_groups_size_communities(conf2)).to(equal(1)) + expect(user_groups_size_legacy_groups(conf2)).to(equal(1)) + + let legacyGroup4: UnsafeMutablePointer? = user_groups_get_legacy_group(conf2, &cDefinitelyRealId) + expect(legacyGroup4?.pointee).toNot(beNil()) + expect(String(libSessionVal: legacyGroup4?.pointee.enc_pubkey, fixedLength: 32)).to(equal("")) + expect(String(libSessionVal: legacyGroup4?.pointee.enc_seckey, fixedLength: 32)).to(equal("")) + expect(legacyGroup4?.pointee.disappearing_timer).to(equal(60)) + expect(String(libSessionVal: legacyGroup4?.pointee.session_id)).to(equal(definitelyRealId)) + expect(legacyGroup4?.pointee.priority).to(equal(3)) + expect(String(libSessionVal: legacyGroup4?.pointee.name)).to(equal("Englishmen")) + expect(legacyGroup4?.pointee.joined_at).to(equal(createdTs)) + expect(legacyGroup2.pointee.notifications).to(equal(CONVO_NOTIFY_ALL)) + expect(legacyGroup2.pointee.mute_until).to(equal(nowTs + 3600)) + + var membersSeen3: [String: Bool] = [:] + var memberSessionId3: UnsafePointer? = nil + var memberAdmin3: Bool = false + let membersIt3: OpaquePointer = ugroups_legacy_members_begin(legacyGroup4) + + while ugroups_legacy_members_next(membersIt3, &memberSessionId3, &memberAdmin3) { + membersSeen3[String(cString: memberSessionId3!)] = memberAdmin3 + } + + ugroups_legacy_members_free(membersIt3) + ugroups_legacy_group_free(legacyGroup4) + + expect(membersSeen3).to(equal([ + "050000000000000000000000000000000000000000000000000000000000000000": false, + "051111111111111111111111111111111111111111111111111111111111111111": false, + "052222222222222222222222222222222222222222222222222222222222222222": true + ])) + + expect(config_needs_push(conf2)).to(beFalse()) + expect(config_needs_dump(conf2)).to(beFalse()) + + let pushData5: UnsafeMutablePointer = config_push(conf2) + expect(pushData5.pointee.seqno).to(equal(1)) + expect(config_needs_dump(conf2)).to(beFalse()) + pushData5.deallocate() + + for targetConf in [conf, conf2] { + // Iterate through and make sure we got everything we expected + var seen: [String] = [] + + var c1: ugroups_legacy_group_info = ugroups_legacy_group_info() + var c2: ugroups_community_info = ugroups_community_info() + let it: OpaquePointer = user_groups_iterator_new(targetConf) + + while !user_groups_iterator_done(it) { + if user_groups_it_is_legacy_group(it, &c1) { + var memberCount: Int = 0 + var adminCount: Int = 0 + ugroups_legacy_members_count(&c1, &memberCount, &adminCount) + seen.append("legacy: \(String(libSessionVal: c1.name)), \(adminCount) admins, \(memberCount) members") + } + else if user_groups_it_is_community(it, &c2) { + seen.append("community: \(String(libSessionVal: c2.base_url))/r/\(String(libSessionVal: c2.room))") + } + else { + seen.append("unknown") + } + + user_groups_iterator_advance(it) + } + + user_groups_iterator_free(it) + + expect(seen).to(equal([ + "community: http://example.org:5678/r/SudokuRoom", + "legacy: Englishmen, 1 admins, 2 members" + ])) + } + + var cCommunity2BaseUrl: [CChar] = "http://example.org:5678".cArray.nullTerminated() + var cCommunity2Room: [CChar] = "sudokuRoom".cArray.nullTerminated() + var community2: ugroups_community_info = ugroups_community_info() + expect(user_groups_get_community(conf2, &community2, &cCommunity2BaseUrl, &cCommunity2Room)) + .to(beTrue()) + expect(String(libSessionVal: community2.base_url)).to(equal("http://example.org:5678")) + expect(String(libSessionVal: community2.room)).to(equal("SudokuRoom")) // Case preserved from the stored value, not the input value + expect(Data(libSessionVal: community2.pubkey, count: 32).toHexString()) + .to(equal("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + expect(community2.priority).to(equal(14)) + + expect(config_needs_push(conf2)).to(beFalse()) + expect(config_needs_dump(conf2)).to(beFalse()) + + let pushData6: UnsafeMutablePointer = config_push(conf2) + expect(pushData6.pointee.seqno).to(equal(1)) + expect(config_needs_dump(conf2)).to(beFalse()) + pushData6.deallocate() + + community2.room = "sudokuRoom".toLibSession() // Change capitalization + user_groups_set_community(conf2, &community2) + + expect(config_needs_push(conf2)).to(beTrue()) + expect(config_needs_dump(conf2)).to(beTrue()) + + let fakeHash2: String = "fakehash2" + var cFakeHash2: [CChar] = fakeHash2.cArray.nullTerminated() + let pushData7: UnsafeMutablePointer = config_push(conf2) + expect(pushData7.pointee.seqno).to(equal(2)) + config_confirm_pushed(conf2, pushData7.pointee.seqno, &cFakeHash2) + expect([String](pointer: pushData7.pointee.obsolete, count: pushData7.pointee.obsolete_len)) + .to(equal([fakeHash1])) + + let currentHashes3: UnsafeMutablePointer? = config_current_hashes(conf2) + expect([String](pointer: currentHashes3?.pointee.value, count: currentHashes3?.pointee.len)) + .to(equal([fakeHash2])) + currentHashes3?.deallocate() + + var dump2: UnsafeMutablePointer? = nil + var dump2Len: Int = 0 + config_dump(conf2, &dump2, &dump2Len) + + expect(config_needs_push(conf2)).to(beFalse()) + expect(config_needs_dump(conf2)).to(beFalse()) + + let pushData8: UnsafeMutablePointer = config_push(conf2) + expect(pushData8.pointee.seqno).to(equal(2)) + config_confirm_pushed(conf2, pushData8.pointee.seqno, &cFakeHash2) + expect(config_needs_dump(conf2)).to(beFalse()) + + var mergeHashes1: [UnsafePointer?] = [cFakeHash2].unsafeCopy() + var mergeData1: [UnsafePointer?] = [UnsafePointer(pushData8.pointee.config)] + var mergeSize1: [Int] = [pushData8.pointee.config_len] + expect(config_merge(conf, &mergeHashes1, &mergeData1, &mergeSize1, 1)).to(equal(1)) + pushData8.deallocate() + + var cCommunity3BaseUrl: [CChar] = "http://example.org:5678".cArray.nullTerminated() + var cCommunity3Room: [CChar] = "SudokuRoom".cArray.nullTerminated() + var community3: ugroups_community_info = ugroups_community_info() + expect(user_groups_get_community(conf, &community3, &cCommunity3BaseUrl, &cCommunity3Room)) + .to(beTrue()) + expect(String(libSessionVal: community3.room)).to(equal("sudokuRoom")) // We picked up the capitalization change + + expect(user_groups_size(conf)).to(equal(2)) + expect(user_groups_size_communities(conf)).to(equal(1)) + expect(user_groups_size_legacy_groups(conf)).to(equal(1)) + + let legacyGroup5: UnsafeMutablePointer? = user_groups_get_legacy_group(conf2, &cDefinitelyRealId) + expect(ugroups_legacy_member_add(legacyGroup5, &cUsers[4], false)).to(beTrue()) + expect(ugroups_legacy_member_add(legacyGroup5, &cUsers[5], true)).to(beTrue()) + expect(ugroups_legacy_member_add(legacyGroup5, &cUsers[6], true)).to(beTrue()) + expect(ugroups_legacy_member_remove(legacyGroup5, &cUsers[1])).to(beTrue()) + + expect(config_needs_push(conf2)).to(beFalse()) + expect(config_needs_dump(conf2)).to(beFalse()) + + let pushData9: UnsafeMutablePointer = config_push(conf2) + expect(pushData9.pointee.seqno).to(equal(2)) + expect(config_needs_dump(conf2)).to(beFalse()) + pushData9.deallocate() + + user_groups_set_free_legacy_group(conf2, legacyGroup5) + expect(config_needs_push(conf2)).to(beTrue()) + expect(config_needs_dump(conf2)).to(beTrue()) + + var cCommunity4BaseUrl: [CChar] = "http://exAMple.ORG:5678".cArray.nullTerminated() + var cCommunity4Room: [CChar] = "sudokuROOM".cArray.nullTerminated() + user_groups_erase_community(conf2, &cCommunity4BaseUrl, &cCommunity4Room) + + let fakeHash3: String = "fakehash3" + var cFakeHash3: [CChar] = fakeHash3.cArray.nullTerminated() + let pushData10: UnsafeMutablePointer = config_push(conf2) + config_confirm_pushed(conf2, pushData10.pointee.seqno, &cFakeHash3) + + expect(pushData10.pointee.seqno).to(equal(3)) + expect([String](pointer: pushData10.pointee.obsolete, count: pushData10.pointee.obsolete_len)) + .to(equal([fakeHash2])) + + let currentHashes4: UnsafeMutablePointer? = config_current_hashes(conf2) + expect([String](pointer: currentHashes4?.pointee.value, count: currentHashes4?.pointee.len)) + .to(equal([fakeHash3])) + currentHashes4?.deallocate() + + var mergeHashes2: [UnsafePointer?] = [cFakeHash3].unsafeCopy() + var mergeData2: [UnsafePointer?] = [UnsafePointer(pushData10.pointee.config)] + var mergeSize2: [Int] = [pushData10.pointee.config_len] + expect(config_merge(conf, &mergeHashes2, &mergeData2, &mergeSize2, 1)).to(equal(1)) + + expect(user_groups_size(conf)).to(equal(1)) + expect(user_groups_size_communities(conf)).to(equal(0)) + expect(user_groups_size_legacy_groups(conf)).to(equal(1)) + + var prio: Int32 = 0 + var cBeanstalkBaseUrl: [CChar] = "http://jacksbeanstalk.org".cArray.nullTerminated() + var cBeanstalkPubkey: [UInt8] = Data( + hex: "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff" + ).cArray + + ["fee", "fi", "fo", "fum"].forEach { room in + var cRoom: [CChar] = room.cArray.nullTerminated() + prio += 1 + + var community4: ugroups_community_info = ugroups_community_info() + expect(user_groups_get_or_construct_community(conf, &community4, &cBeanstalkBaseUrl, &cRoom, &cBeanstalkPubkey)) + .to(beTrue()) + community4.priority = prio + user_groups_set_community(conf, &community4) + } + + expect(user_groups_size(conf)).to(equal(5)) + expect(user_groups_size_communities(conf)).to(equal(4)) + expect(user_groups_size_legacy_groups(conf)).to(equal(1)) + + let fakeHash4: String = "fakehash4" + var cFakeHash4: [CChar] = fakeHash4.cArray.nullTerminated() + let pushData11: UnsafeMutablePointer = config_push(conf) + config_confirm_pushed(conf, pushData11.pointee.seqno, &cFakeHash4) + expect(pushData11.pointee.seqno).to(equal(4)) + expect([String](pointer: pushData11.pointee.obsolete, count: pushData11.pointee.obsolete_len)) + .to(equal([fakeHash3, fakeHash2, fakeHash1])) + + // Load some obsolete ones in just to check that they get immediately obsoleted + let fakeHash10: String = "fakehash10" + let cFakeHash10: [CChar] = fakeHash10.cArray.nullTerminated() + let fakeHash11: String = "fakehash11" + let cFakeHash11: [CChar] = fakeHash11.cArray.nullTerminated() + let fakeHash12: String = "fakehash12" + let cFakeHash12: [CChar] = fakeHash12.cArray.nullTerminated() + var mergeHashes3: [UnsafePointer?] = [cFakeHash10, cFakeHash11, cFakeHash12, cFakeHash4].unsafeCopy() + var mergeData3: [UnsafePointer?] = [ + UnsafePointer(pushData10.pointee.config), + UnsafePointer(pushData2.pointee.config), + UnsafePointer(pushData7.pointee.config), + UnsafePointer(pushData11.pointee.config) + ] + var mergeSize3: [Int] = [ + pushData10.pointee.config_len, + pushData2.pointee.config_len, + pushData7.pointee.config_len, + pushData11.pointee.config_len + ] + expect(config_merge(conf2, &mergeHashes3, &mergeData3, &mergeSize3, 4)).to(equal(4)) + expect(config_needs_dump(conf2)).to(beTrue()) + expect(config_needs_push(conf2)).to(beFalse()) + pushData2.deallocate() + pushData7.deallocate() + pushData10.deallocate() + pushData11.deallocate() + + let currentHashes5: UnsafeMutablePointer? = config_current_hashes(conf2) + expect([String](pointer: currentHashes5?.pointee.value, count: currentHashes5?.pointee.len)) + .to(equal([fakeHash4])) + currentHashes5?.deallocate() + + let pushData12: UnsafeMutablePointer = config_push(conf2) + expect(pushData12.pointee.seqno).to(equal(4)) + expect([String](pointer: pushData12.pointee.obsolete, count: pushData12.pointee.obsolete_len)) + .to(equal([fakeHash11, fakeHash12, fakeHash10, fakeHash3])) + pushData12.deallocate() + + for targetConf in [conf, conf2] { + // Iterate through and make sure we got everything we expected + var seen: [String] = [] + + var c1: ugroups_legacy_group_info = ugroups_legacy_group_info() + var c2: ugroups_community_info = ugroups_community_info() + let it: OpaquePointer = user_groups_iterator_new(targetConf) + + while !user_groups_iterator_done(it) { + if user_groups_it_is_legacy_group(it, &c1) { + var memberCount: Int = 0 + var adminCount: Int = 0 + ugroups_legacy_members_count(&c1, &memberCount, &adminCount) + + seen.append("legacy: \(String(libSessionVal: c1.name)), \(adminCount) admins, \(memberCount) members") + } + else if user_groups_it_is_community(it, &c2) { + seen.append("community: \(String(libSessionVal: c2.base_url))/r/\(String(libSessionVal: c2.room))") + } + else { + seen.append("unknown") + } + + user_groups_iterator_advance(it) + } + + user_groups_iterator_free(it) + + expect(seen).to(equal([ + "community: http://jacksbeanstalk.org/r/fee", + "community: http://jacksbeanstalk.org/r/fi", + "community: http://jacksbeanstalk.org/r/fo", + "community: http://jacksbeanstalk.org/r/fum", + "legacy: Englishmen, 3 admins, 2 members" + ])) + } + } + } + } +} diff --git a/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift new file mode 100644 index 000000000..ce0ba7a6e --- /dev/null +++ b/SessionMessagingKitTests/LibSessionUtil/Configs/ConfigUserProfileSpec.swift @@ -0,0 +1,399 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtil +import SessionUtilitiesKit +import SessionMessagingKit + +import Quick +import Nimble + +/// This spec is designed to replicate the initial test cases for the libSession-util to ensure the behaviour matches +class ConfigUserProfileSpec { + // MARK: - Spec + + static func spec() { + context("USER_PROFILE") { + it("generates config correctly") { + let seed: Data = Data(hex: "0123456789abcdef0123456789abcdef") + + // FIXME: Would be good to move these into the libSession-util instead of using Sodium separately + let identity = try! Identity.generate(from: seed) + var edSK: [UInt8] = identity.ed25519KeyPair.secretKey + expect(edSK.toHexString().suffix(64)) + .to(equal("4cb76fdc6d32278e3f83dbf608360ecc6b65727934b85d2fb86862ff98c46ab7")) + expect(identity.x25519KeyPair.publicKey.toHexString()) + .to(equal("d2ad010eeb72d72e561d9de7bd7b6989af77dcabffa03a5111a6c859ae5c3a72")) + expect(String(edSK.toHexString().prefix(32))).to(equal(seed.toHexString())) + + // Initialize a brand new, empty config because we have no dump data to deal with. + let error: UnsafeMutablePointer? = nil + var conf: UnsafeMutablePointer? = nil + expect(user_profile_init(&conf, &edSK, nil, 0, error)).to(equal(0)) + error?.deallocate() + + // We don't need to push anything, since this is an empty config + expect(config_needs_push(conf)).to(beFalse()) + // And we haven't changed anything so don't need to dump to db + expect(config_needs_dump(conf)).to(beFalse()) + + // Since it's empty there shouldn't be a name. + let namePtr: UnsafePointer? = user_profile_get_name(conf) + expect(namePtr).to(beNil()) + + // We don't need to push since we haven't changed anything, so this call is mainly just for + // testing: + let pushData1: UnsafeMutablePointer = config_push(conf) + expect(pushData1.pointee).toNot(beNil()) + expect(pushData1.pointee.seqno).to(equal(0)) + expect(pushData1.pointee.config_len).to(equal(256)) + + let encDomain: [CChar] = "UserProfile" + .bytes + .map { CChar(bitPattern: $0) } + expect(String(cString: config_encryption_domain(conf))).to(equal("UserProfile")) + + var toPushDecSize: Int = 0 + let toPushDecrypted: UnsafeMutablePointer? = config_decrypt(pushData1.pointee.config, pushData1.pointee.config_len, edSK, encDomain, &toPushDecSize) + let prefixPadding: String = (0..<193) + .map { _ in "\0" } + .joined() + expect(toPushDecrypted).toNot(beNil()) + expect(toPushDecSize).to(equal(216)) // 256 - 40 overhead + expect(String(pointer: toPushDecrypted, length: toPushDecSize)) + .to(equal("\(prefixPadding)d1:#i0e1:&de1:? = user_profile_get_name(conf) + expect(namePtr2).toNot(beNil()) + expect(String(cString: namePtr2!)).to(equal("Kallie")) + + let pic2: user_profile_pic = user_profile_get_pic(conf); + expect(String(libSessionVal: pic2.url)).to(equal("http://example.org/omg-pic-123.bmp")) + expect(Data(libSessionVal: pic2.key, count: ProfileManager.avatarAES256KeyByteLength)) + .to(equal("secret78901234567890123456789012".data(using: .utf8))) + expect(user_profile_get_nts_priority(conf)).to(equal(9)) + + // Since we've made changes, we should need to push new config to the swarm, *and* should need + // to dump the updated state: + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_dump(conf)).to(beTrue()) + + // incremented since we made changes (this only increments once between + // dumps; even though we changed two fields here). + let pushData2: UnsafeMutablePointer = config_push(conf) + expect(pushData2.pointee.seqno).to(equal(1)) + + // Note: This hex value differs from the value in the library tests because + // it looks like the library has an "end of cell mark" character added at the + // end (0x07 or '0007') so we need to manually add it to work + let expHash0: [UInt8] = Data(hex: "ea173b57beca8af18c3519a7bbf69c3e7a05d1c049fa9558341d8ebb48b0c965") + .bytes + // The data to be actually pushed, expanded like this to make it somewhat human-readable: + let expPush1Decrypted: [UInt8] = [""" + d + 1:#i1e + 1:& d + 1:+ i9e + 1:n 6:Kallie + 1:p 34:http://example.org/omg-pic-123.bmp + 1:q 32:secret78901234567890123456789012 + e + 1:< l + l i0e 32: + """.removeCharacters(characterSet: CharacterSet.whitespacesAndNewlines) // For readability + .bytes, + expHash0, + """ + de e + e + 1:= d + 1:+ 0: + 1:n 0: + 1:p 0: + 1:q 0: + e + e + """.removeCharacters(characterSet: CharacterSet.whitespacesAndNewlines) // For readability + .bytes + ].flatMap { $0 } + let expPush1Encrypted: [UInt8] = Data(hex: [ + "9693a69686da3055f1ecdfb239c3bf8e746951a36d888c2fb7c02e856a5c2091b24e39a7e1af828f", + "1fa09fe8bf7d274afde0a0847ba143c43ffb8722301b5ae32e2f078b9a5e19097403336e50b18c84", + "aade446cd2823b011f97d6ad2116a53feb814efecc086bc172d31f4214b4d7c630b63bbe575b0868", + "2d146da44915063a07a78556ab5eff4f67f6aa26211e8d330b53d28567a931028c393709a325425d", + "e7486ccde24416a7fd4a8ba5fa73899c65f4276dfaddd5b2100adcf0f793104fb235b31ce32ec656", + "056009a9ebf58d45d7d696b74e0c7ff0499c4d23204976f19561dc0dba6dc53a2497d28ce03498ea", + "49bf122762d7bc1d6d9c02f6d54f8384" + ].joined()).bytes + + let pushData2Str: String = String(pointer: pushData2.pointee.config, length: pushData2.pointee.config_len, encoding: .ascii)! + let expPush1EncryptedStr: String = String(pointer: expPush1Encrypted, length: expPush1Encrypted.count, encoding: .ascii)! + expect(pushData2Str).to(equal(expPush1EncryptedStr)) + + // Raw decryption doesn't unpad (i.e. the padding is part of the encrypted data) + var pushData2DecSize: Int = 0 + let pushData2Decrypted: UnsafeMutablePointer? = config_decrypt( + pushData2.pointee.config, + pushData2.pointee.config_len, + edSK, + encDomain, + &pushData2DecSize + ) + let prefixPadding2: String = (0..<(256 - 40 - expPush1Decrypted.count)) + .map { _ in "\0" } + .joined() + expect(pushData2DecSize).to(equal(216)) // 256 - 40 overhead + + let pushData2DecryptedStr: String = String(pointer: pushData2Decrypted, length: pushData2DecSize, encoding: .ascii)! + let expPush1DecryptedStr: String = String(pointer: expPush1Decrypted, length: expPush1Decrypted.count, encoding: .ascii) + .map { "\(prefixPadding2)\($0)" }! + expect(pushData2DecryptedStr).to(equal(expPush1DecryptedStr)) + pushData2Decrypted?.deallocate() + + // We haven't dumped, so still need to dump: + expect(config_needs_dump(conf)).to(beTrue()) + // We did call push, but we haven't confirmed it as stored yet, so this will still return true: + expect(config_needs_push(conf)).to(beTrue()) + + var dump1: UnsafeMutablePointer? = nil + var dump1Len: Int = 0 + + config_dump(conf, &dump1, &dump1Len) + // (in a real client we'd now store this to disk) + + expect(config_needs_dump(conf)).to(beFalse()) + + let expDump1: [CChar] = [ + """ + d + 1:! i2e + 1:$ \(expPush1Decrypted.count): + """ + .removeCharacters(characterSet: CharacterSet.whitespacesAndNewlines) + .bytes + .map { CChar(bitPattern: $0) }, + expPush1Decrypted + .map { CChar(bitPattern: $0) }, + """ + 1:(0: + 1:)le + e + """.removeCharacters(characterSet: CharacterSet.whitespacesAndNewlines) + .bytes + .map { CChar(bitPattern: $0) } + ].flatMap { $0 } + expect(String(pointer: dump1, length: dump1Len, encoding: .ascii)) + .to(equal(String(pointer: expDump1, length: expDump1.count, encoding: .ascii))) + dump1?.deallocate() + + // So now imagine we got back confirmation from the swarm that the push has been stored: + let fakeHash1: String = "fakehash1" + var cFakeHash1: [CChar] = fakeHash1.cArray.nullTerminated() + config_confirm_pushed(conf, pushData2.pointee.seqno, &cFakeHash1) + pushData2.deallocate() + + expect(config_needs_push(conf)).to(beFalse()) + expect(config_needs_dump(conf)).to(beTrue()) // The confirmation changes state, so this makes us need a dump + + var dump2: UnsafeMutablePointer? = nil + var dump2Len: Int = 0 + config_dump(conf, &dump2, &dump2Len) + + let expDump2: [CChar] = [ + """ + d + 1:! i0e + 1:$ \(expPush1Decrypted.count): + """ + .removeCharacters(characterSet: CharacterSet.whitespacesAndNewlines) + .bytes + .map { CChar(bitPattern: $0) }, + expPush1Decrypted + .map { CChar(bitPattern: $0) }, + """ + 1:(9:fakehash1 + 1:)le + e + """.removeCharacters(characterSet: CharacterSet.whitespacesAndNewlines) + .bytes + .map { CChar(bitPattern: $0) } + ].flatMap { $0 } + expect(String(pointer: dump2, length: dump2Len, encoding: .ascii)) + .to(equal(String(pointer: expDump2, length: expDump2.count, encoding: .ascii))) + dump2?.deallocate() + expect(config_needs_dump(conf)).to(beFalse()) + + // Now we're going to set up a second, competing config object (in the real world this would be + // another Session client somewhere). + + // Start with an empty config, as above: + let error2: UnsafeMutablePointer? = nil + var conf2: UnsafeMutablePointer? = nil + expect(user_profile_init(&conf2, &edSK, nil, 0, error2)).to(equal(0)) + expect(config_needs_dump(conf2)).to(beFalse()) + error2?.deallocate() + + // Now imagine we just pulled down the `exp_push1` string from the swarm; we merge it into + // conf2: + var mergeHashes: [UnsafePointer?] = [cFakeHash1].unsafeCopy() + var mergeData: [UnsafePointer?] = [expPush1Encrypted].unsafeCopy() + var mergeSize: [Int] = [expPush1Encrypted.count] + expect(config_merge(conf2, &mergeHashes, &mergeData, &mergeSize, 1)).to(equal(1)) + mergeHashes.forEach { $0?.deallocate() } + mergeData.forEach { $0?.deallocate() } + + // Our state has changed, so we need to dump: + expect(config_needs_dump(conf2)).to(beTrue()) + var dump3: UnsafeMutablePointer? = nil + var dump3Len: Int = 0 + config_dump(conf2, &dump3, &dump3Len) + // (store in db) + dump3?.deallocate() + expect(config_needs_dump(conf2)).to(beFalse()) + + // We *don't* need to push: even though we updated, all we did is update to the merged data (and + // didn't have any sort of merge conflict needed): + expect(config_needs_push(conf2)).to(beFalse()) + + // Now let's create a conflicting update: + + // Change the name on both clients: + user_profile_set_name(conf, "Nibbler") + user_profile_set_name(conf2, "Raz") + + // And, on conf2, we're also going to change the profile pic: + let p2: user_profile_pic = user_profile_pic( + url: "http://new.example.com/pic".toLibSession(), + key: "qwert\0yuio1234567890123456789012".data(using: .utf8)!.toLibSession() + ) + user_profile_set_pic(conf2, p2) + + // Both have changes, so push need a push + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_push(conf2)).to(beTrue()) + + let fakeHash2: String = "fakehash2" + var cFakeHash2: [CChar] = fakeHash2.cArray.nullTerminated() + let pushData3: UnsafeMutablePointer = config_push(conf) + expect(pushData3.pointee.seqno).to(equal(2)) // incremented, since we made a field change + config_confirm_pushed(conf, pushData3.pointee.seqno, &cFakeHash2) + + let fakeHash3: String = "fakehash3" + var cFakeHash3: [CChar] = fakeHash3.cArray.nullTerminated() + let pushData4: UnsafeMutablePointer = config_push(conf2) + expect(pushData4.pointee.seqno).to(equal(2)) // incremented, since we made a field change + config_confirm_pushed(conf, pushData4.pointee.seqno, &cFakeHash3) + + var dump4: UnsafeMutablePointer? = nil + var dump4Len: Int = 0 + config_dump(conf, &dump4, &dump4Len); + var dump5: UnsafeMutablePointer? = nil + var dump5Len: Int = 0 + config_dump(conf2, &dump5, &dump5Len); + // (store in db) + dump4?.deallocate() + dump5?.deallocate() + + // Since we set different things, we're going to get back different serialized data to be + // pushed: + let pushData3Str: String? = String(pointer: pushData3.pointee.config, length: pushData3.pointee.config_len, encoding: .ascii) + let pushData4Str: String? = String(pointer: pushData4.pointee.config, length: pushData4.pointee.config_len, encoding: .ascii) + expect(pushData3Str).toNot(equal(pushData4Str)) + + // Now imagine that each client pushed its `seqno=2` config to the swarm, but then each client + // also fetches new messages and pulls down the other client's `seqno=2` value. + + // Feed the new config into each other. (This array could hold multiple configs if we pulled + // down more than one). + var mergeHashes2: [UnsafePointer?] = [cFakeHash2].unsafeCopy() + var mergeData2: [UnsafePointer?] = [UnsafePointer(pushData3.pointee.config)] + var mergeSize2: [Int] = [pushData3.pointee.config_len] + expect(config_merge(conf2, &mergeHashes2, &mergeData2, &mergeSize2, 1)).to(equal(1)) + pushData3.deallocate() + var mergeHashes3: [UnsafePointer?] = [cFakeHash3].unsafeCopy() + var mergeData3: [UnsafePointer?] = [UnsafePointer(pushData4.pointee.config)] + var mergeSize3: [Int] = [pushData4.pointee.config_len] + expect(config_merge(conf, &mergeHashes3, &mergeData3, &mergeSize3, 1)).to(equal(1)) + pushData4.deallocate() + + // Now after the merge we *will* want to push from both client, since both will have generated a + // merge conflict update (with seqno = 3). + expect(config_needs_push(conf)).to(beTrue()) + expect(config_needs_push(conf2)).to(beTrue()) + let pushData5: UnsafeMutablePointer = config_push(conf) + let pushData6: UnsafeMutablePointer = config_push(conf2) + expect(pushData5.pointee.seqno).to(equal(3)) + expect(pushData6.pointee.seqno).to(equal(3)) + + // They should have resolved the conflict to the same thing: + expect(String(cString: user_profile_get_name(conf)!)).to(equal("Nibbler")) + expect(String(cString: user_profile_get_name(conf2)!)).to(equal("Nibbler")) + // (Note that they could have also both resolved to "Raz" here, but the hash of the serialized + // message just happens to have a higher hash -- and thus gets priority -- for this particular + // test). + + // Since only one of them set a profile pic there should be no conflict there: + let pic3: user_profile_pic = user_profile_get_pic(conf) + expect(pic3.url).toNot(beNil()) + expect(String(libSessionVal: pic3.url)).to(equal("http://new.example.com/pic")) + expect(pic3.key).toNot(beNil()) + expect(Data(libSessionVal: pic3.key, count: 32).toHexString()) + .to(equal("7177657274007975696f31323334353637383930313233343536373839303132")) + let pic4: user_profile_pic = user_profile_get_pic(conf2) + expect(pic4.url).toNot(beNil()) + expect(String(libSessionVal: pic4.url)).to(equal("http://new.example.com/pic")) + expect(pic4.key).toNot(beNil()) + expect(Data(libSessionVal: pic4.key, count: 32).toHexString()) + .to(equal("7177657274007975696f31323334353637383930313233343536373839303132")) + expect(user_profile_get_nts_priority(conf)).to(equal(9)) + expect(user_profile_get_nts_priority(conf2)).to(equal(9)) + + let fakeHash4: String = "fakehash4" + var cFakeHash4: [CChar] = fakeHash4.cArray.nullTerminated() + let fakeHash5: String = "fakehash5" + var cFakeHash5: [CChar] = fakeHash5.cArray.nullTerminated() + config_confirm_pushed(conf, pushData5.pointee.seqno, &cFakeHash4) + config_confirm_pushed(conf2, pushData6.pointee.seqno, &cFakeHash5) + pushData5.deallocate() + pushData6.deallocate() + + var dump6: UnsafeMutablePointer? = nil + var dump6Len: Int = 0 + config_dump(conf, &dump6, &dump6Len); + var dump7: UnsafeMutablePointer? = nil + var dump7Len: Int = 0 + config_dump(conf2, &dump7, &dump7Len); + // (store in db) + dump6?.deallocate() + dump7?.deallocate() + + expect(config_needs_dump(conf)).to(beFalse()) + expect(config_needs_dump(conf2)).to(beFalse()) + expect(config_needs_push(conf)).to(beFalse()) + expect(config_needs_push(conf2)).to(beFalse()) + + // Wouldn't do this in a normal session but doing it here to properly clean up + // after the test + conf?.deallocate() + conf2?.deallocate() + } + } + } +} diff --git a/SessionMessagingKitTests/LibSessionUtil/LibSessionSpec.swift b/SessionMessagingKitTests/LibSessionUtil/LibSessionSpec.swift new file mode 100644 index 000000000..42d0746d7 --- /dev/null +++ b/SessionMessagingKitTests/LibSessionUtil/LibSessionSpec.swift @@ -0,0 +1,22 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtil +import SessionUtilitiesKit + +import Quick +import Nimble + +class LibSessionSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("libSession") { + ConfigContactsSpec.spec() + ConfigUserProfileSpec.spec() + ConfigConvoInfoVolatileSpec.spec() + ConfigUserGroupsSpec.spec() + } + } +} diff --git a/SessionMessagingKitTests/LibSessionUtil/SessionUtilSpec.swift b/SessionMessagingKitTests/LibSessionUtil/SessionUtilSpec.swift new file mode 100644 index 000000000..e8ac18129 --- /dev/null +++ b/SessionMessagingKitTests/LibSessionUtil/SessionUtilSpec.swift @@ -0,0 +1,212 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Foundation +import Sodium +import SessionUtil +import SessionUtilitiesKit +import SessionMessagingKit + +import Quick +import Nimble + +class SessionUtilSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("SessionUtil") { + // MARK: - Parsing URLs + + context("when parsing a community 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" + ], + [ + "https://143.198.213.225:443/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ], + [ + "https://143.198.213.225:443/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ], + [ + "http://143.198.213.255:80/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ], + [ + "http://143.198.213.255:80/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ] + ].map { $0.joined() } + let processedValues: [(room: String, server: String, publicKey: String)] = validUrls + .map { SessionUtil.parseCommunity(url: $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: 8) + let expectedServers: [String] = [ + "https://sessionopengroup.co", + "https://sessionopengroup.co", + "http://sessionopengroup.co", + "http://sessionopengroup.co", + "https://143.198.213.225", + "https://143.198.213.225", + "http://143.198.213.255", + "http://143.198.213.255" + ] + let expectedPublicKeys: [String] = [String]( + repeating: "658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c", + count: 8 + ) + + 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 = SessionUtil.parseCommunity( + url: [ + "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 no scheme is provided") { + let info = SessionUtil.parseCommunity( + url: [ + "sessionopengroup.co/r/main?", + "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 room") { + let info = SessionUtil.parseCommunity( + url: [ + "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 = SessionUtil.parseCommunity( + url: "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 = SessionUtil.parseCommunity( + url: [ + "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 = SessionUtil.parseCommunity( + url: [ + "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 = SessionUtil.parseCommunity( + url: [ + "http://sessionopengroup.co/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + )?.server + let server2 = SessionUtil.parseCommunity( + url: [ + "https://sessionopengroup.co/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + )?.server + + expect(server1).to(equal("http://sessionopengroup.co")) + expect(server2).to(equal("https://sessionopengroup.co")) + } + + it("maintains the same port") { + let server1 = SessionUtil.parseCommunity( + url: [ + "https://sessionopengroup.co/r/main?", + "public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c" + ].joined() + )?.server + let server2 = SessionUtil.parseCommunity( + url: [ + "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: - Generating URLs + + context("when generating a url") { + it("generates the url correctly") { + expect(SessionUtil.communityUrlFor(server: "server", roomToken: "room", publicKey: "f8fec9b701000000ffffffff0400008000000000000000000000000000000000")) + .to(equal("server/room?public_key=f8fec9b701000000ffffffff0400008000000000000000000000000000000000")) + } + + it("maintains the casing provided") { + expect(SessionUtil.communityUrlFor(server: "SeRVer", roomToken: "RoOM", publicKey: "f8fec9b701000000ffffffff0400008000000000000000000000000000000000")) + .to(equal("SeRVer/RoOM?public_key=f8fec9b701000000ffffffff0400008000000000000000000000000000000000")) + } + } + } + } +} diff --git a/SessionMessagingKitTests/LibSessionUtil/Utilities/LibSessionTypeConversionUtilitiesSpec.swift b/SessionMessagingKitTests/LibSessionUtil/Utilities/LibSessionTypeConversionUtilitiesSpec.swift new file mode 100644 index 000000000..5aeeff2b3 --- /dev/null +++ b/SessionMessagingKitTests/LibSessionUtil/Utilities/LibSessionTypeConversionUtilitiesSpec.swift @@ -0,0 +1,343 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class LibSessionTypeConversionUtilitiesSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + // MARK: - String + + describe("a String") { + it("can convert to a cArray") { + expect("Test123".cArray).to(equal([84, 101, 115, 116, 49, 50, 51])) + } + + it("can contain emoji") { + let original: String = "Hi 👋" + let libSessionVal: (CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar, CChar) = original.toLibSession() + let result: String? = String(libSessionVal: libSessionVal) + + expect(result).to(equal(original)) + } + + context("when initialised with a pointer and length") { + it("returns null when given a null pointer") { + let test: [CChar] = [84, 101, 115, 116] + let result = test.withUnsafeBufferPointer { ptr in + String(pointer: nil, length: 5) + } + + expect(result).to(beNil()) + } + + it("returns a truncated string when given an incorrect length") { + let test: [CChar] = [84, 101, 115, 116] + let result = test.withUnsafeBufferPointer { ptr in + String(pointer: UnsafeRawPointer(ptr.baseAddress), length: 2) + } + + expect(result).to(equal("Te")) + } + + it("returns a string when valid") { + let test: [CChar] = [84, 101, 115, 116] + let result = test.withUnsafeBufferPointer { ptr in + String(pointer: UnsafeRawPointer(ptr.baseAddress), length: 4) + } + + expect(result).to(equal("Test")) + } + } + + context("when initialised with a libSession value") { + it("returns a string when valid and has no fixed length") { + let value: (CChar, CChar, CChar, CChar, CChar) = (84, 101, 115, 116, 0) + let result = String(libSessionVal: value, fixedLength: .none) + + expect(result).to(equal("Test")) + } + + it("returns a string when valid and has a fixed length") { + let value: (CChar, CChar, CChar, CChar, CChar) = (84, 101, 0, 115, 116) + let result = String(libSessionVal: value, fixedLength: 5) + + expect(result).to(equal("Te\0st")) + } + + it("truncates at the first null termination character when fixed length is none") { + let value: (CChar, CChar, CChar, CChar, CChar) = (84, 101, 0, 115, 116) + let result = String(libSessionVal: value, fixedLength: .none) + + expect(result).to(equal("Te")) + } + + it("parses successfully if there is no null termination character and there is no fixed length") { + let value: (CChar, CChar, CChar, CChar, CChar) = (84, 101, 115, 116, 84) + let result = String(libSessionVal: value, fixedLength: .none) + + expect(result).to(equal("TestT")) + } + + it("returns an empty string when given a value only containing null termination characters with a fixed length") { + let value: (CChar, CChar, CChar, CChar, CChar) = (0, 0, 0, 0, 0) + let result = String(libSessionVal: value, fixedLength: 5) + + expect(result).to(equal("")) + } + + it("defaults the fixed length value to none") { + let value: (CChar, CChar, CChar, CChar, CChar) = (84, 101, 0, 0, 0) + let result = String(libSessionVal: value) + + expect(result).to(equal("Te")) + } + + it("returns an empty string when null and not set to return null") { + let value: (CChar, CChar, CChar, CChar, CChar) = (0, 0, 0, 0, 0) + let result = String(libSessionVal: value, nullIfEmpty: false) + + expect(result).to(equal("")) + } + + it("returns null when specified and empty") { + let value: (CChar, CChar, CChar, CChar, CChar) = (0, 0, 0, 0, 0) + let result = String(libSessionVal: value, nullIfEmpty: true) + + expect(result).to(beNil()) + } + + it("defaults the null if empty flag to false") { + let value: (CChar, CChar, CChar, CChar, CChar) = (0, 0, 0, 0, 0) + let result = String(libSessionVal: value) + + expect(result).to(equal("")) + } + } + + context("when converting to a libSession value") { + it("succeeeds with a valid value") { + let result: (CChar, CChar, CChar, CChar, CChar) = "Test".toLibSession() + expect(result.0).to(equal(84)) + expect(result.1).to(equal(101)) + expect(result.2).to(equal(115)) + expect(result.3).to(equal(116)) + expect(result.4).to(equal(0)) + } + + it("truncates when too long") { + let result: (CChar, CChar, CChar, CChar, CChar) = "TestTest".toLibSession() + expect(result.0).to(equal(84)) + expect(result.1).to(equal(101)) + expect(result.2).to(equal(115)) + expect(result.3).to(equal(116)) + expect(result.4).to(equal(84)) + } + + context("when optional") { + context("returns empty when null") { + let value: String? = nil + let result: (CChar, CChar, CChar, CChar, CChar) = value.toLibSession() + + expect(result.0).to(equal(0)) + expect(result.1).to(equal(0)) + expect(result.2).to(equal(0)) + expect(result.3).to(equal(0)) + expect(result.4).to(equal(0)) + } + + context("returns a libSession value when not null") { + let value: String? = "Test" + let result: (CChar, CChar, CChar, CChar, CChar) = value.toLibSession() + + expect(result.0).to(equal(84)) + expect(result.1).to(equal(101)) + expect(result.2).to(equal(115)) + expect(result.3).to(equal(116)) + expect(result.4).to(equal(0)) + } + } + } + } + + // MARK: - Data + + describe("Data") { + it("can convert to a cArray") { + expect(Data([1, 2, 3]).cArray).to(equal([1, 2, 3])) + } + + context("when initialised with a libSession value") { + it("returns truncated data when given the wrong length") { + let value: (UInt8, UInt8, UInt8, UInt8, UInt8) = (1, 2, 3, 4, 5) + let result = Data(libSessionVal: value, count: 2) + + expect(result).to(equal(Data([1, 2]))) + } + + it("returns data when valid") { + let value: (UInt8, UInt8, UInt8, UInt8, UInt8) = (1, 2, 3, 4, 5) + let result = Data(libSessionVal: value, count: 5) + + expect(result).to(equal(Data([1, 2, 3, 4, 5]))) + } + + it("returns data when all bytes are zero and nullIfEmpty is false") { + let value: (UInt8, UInt8, UInt8, UInt8, UInt8) = (0, 0, 0, 0, 0) + let result = Data(libSessionVal: value, count: 5, nullIfEmpty: false) + + expect(result).to(equal(Data([0, 0, 0, 0, 0]))) + } + + it("returns null when all bytes are zero and nullIfEmpty is true") { + let value: (UInt8, UInt8, UInt8, UInt8, UInt8) = (0, 0, 0, 0, 0) + let result = Data(libSessionVal: value, count: 5, nullIfEmpty: true) + + expect(result).to(beNil()) + } + } + + context("when converting to a libSession value") { + it("succeeeds with a valid value") { + let result: (Int8, Int8, Int8, Int8, Int8) = Data([1, 2, 3, 4, 5]).toLibSession() + expect(result.0).to(equal(1)) + expect(result.1).to(equal(2)) + expect(result.2).to(equal(3)) + expect(result.3).to(equal(4)) + expect(result.4).to(equal(5)) + } + + it("truncates when too long") { + let result: (Int8, Int8, Int8, Int8, Int8) = Data([1, 2, 3, 4, 1, 2, 3, 4]).toLibSession() + expect(result.0).to(equal(1)) + expect(result.1).to(equal(2)) + expect(result.2).to(equal(3)) + expect(result.3).to(equal(4)) + expect(result.4).to(equal(1)) + } + + context("fills with empty data when too short") { + let value: Data? = Data([1, 2, 3]) + let result: (Int8, Int8, Int8, Int8, Int8) = value.toLibSession() + + expect(result.0).to(equal(1)) + expect(result.1).to(equal(2)) + expect(result.2).to(equal(3)) + expect(result.3).to(equal(0)) + expect(result.4).to(equal(0)) + } + + context("when optional") { + context("returns null when null") { + let value: Data? = nil + let result: (Int8, Int8, Int8, Int8, Int8) = value.toLibSession() + + expect(result.0).to(equal(0)) + expect(result.1).to(equal(0)) + expect(result.2).to(equal(0)) + expect(result.3).to(equal(0)) + expect(result.4).to(equal(0)) + } + + context("returns a libSession value when not null") { + let value: Data? = Data([1, 2, 3, 4, 5]) + let result: (Int8, Int8, Int8, Int8, Int8) = value.toLibSession() + + expect(result.0).to(equal(1)) + expect(result.1).to(equal(2)) + expect(result.2).to(equal(3)) + expect(result.3).to(equal(4)) + expect(result.4).to(equal(5)) + } + } + } + } + + // MARK: - Array + + describe("an Array") { + context("when initialised with a 2D C array") { + it("returns the correct array") { + var test: [CChar] = ( + "Test1".cArray.nullTerminated() + + "Test2".cArray.nullTerminated() + + "Test3AndExtra".cArray.nullTerminated() + ) + let result = test.withUnsafeMutableBufferPointer { ptr in + var mutablePtr = UnsafeMutablePointer(ptr.baseAddress) + + return [String](pointer: &mutablePtr, count: 3) + } + + expect(result).to(equal(["Test1", "Test2", "Test3AndExtra"])) + } + + it("returns an empty array if given one") { + var test = [CChar]() + let result = test.withUnsafeMutableBufferPointer { ptr in + var mutablePtr = UnsafeMutablePointer(ptr.baseAddress) + + return [String](pointer: &mutablePtr, count: 0) + } + + expect(result).to(equal([])) + } + + it("handles empty strings without issues") { + var test: [CChar] = ( + "Test1".cArray.nullTerminated() + + "".cArray.nullTerminated() + + "Test2".cArray.nullTerminated() + ) + let result = test.withUnsafeMutableBufferPointer { ptr in + var mutablePtr = UnsafeMutablePointer(ptr.baseAddress) + + return [String](pointer: &mutablePtr, count: 3) + } + + expect(result).to(equal(["Test1", "", "Test2"])) + } + + it("returns null when given a null pointer") { + expect([String](pointer: nil, count: 5)).to(beNil()) + } + + it("returns null when given a null count") { + var test: [CChar] = "Test1".cArray.nullTerminated() + let result = test.withUnsafeMutableBufferPointer { ptr in + var mutablePtr = UnsafeMutablePointer(ptr.baseAddress) + + return [String](pointer: &mutablePtr, count: nil) + } + + expect(result).to(beNil()) + } + + it("returns the default value if given null values") { + expect([String](pointer: nil, count: 5, defaultValue: ["Test"])) + .to(equal(["Test"])) + } + } + + context("when adding a null terminated character") { + it("adds a null termination character when not present") { + let value: [CChar] = [1, 2, 3, 4, 5] + + expect(value.nullTerminated()).to(equal([1, 2, 3, 4, 5, 0])) + } + + it("adds nothing when already present") { + let value: [CChar] = [1, 2, 3, 4, 0] + + expect(value.nullTerminated()).to(equal([1, 2, 3, 4, 0])) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift index 97fbebfdb..345b3044d 100644 --- a/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/BatchRequestInfoSpec.swift @@ -1,7 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit +import Combine import SessionSnodeKit import SessionUtilitiesKit @@ -19,367 +19,162 @@ class BatchRequestInfoSpec: QuickSpec { // MARK: - Spec override func spec() { - // MARK: - BatchSubRequest + // MARK: - BatchRequest.Child - 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"])) - } - } + describe("a BatchRequest.Child") { + var request: OpenGroupAPI.BatchRequest! context("when encoding") { it("successfully encodes a string body") { - subRequest = OpenGroupAPI.BatchSubRequest( - request: Request( - method: .get, - server: "testServer", - endpoint: .batch, - queryParameters: [:], - headers: [:], - body: "testBody" - ) + request = OpenGroupAPI.BatchRequest( + requests: [ + OpenGroupAPI.PreparedSendData( + request: Request( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [:], + body: "testBody" + ), + urlRequest: URLRequest(url: URL(string: "https://www.oxen.io")!), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 + ) + ] ) - let subRequestData: Data = try! JSONEncoder().encode(subRequest) - let subRequestString: String? = String(data: subRequestData, encoding: .utf8) + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) - expect(subRequestString) - .to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"b64\":\"testBody\"}")) + expect(requestString) + .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] - ) + request = OpenGroupAPI.BatchRequest( + requests: [ + OpenGroupAPI.PreparedSendData( + request: Request<[UInt8], OpenGroupAPI.Endpoint>( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [:], + body: [1, 2, 3] + ), + urlRequest: URLRequest(url: URL(string: "https://www.oxen.io")!), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 + ) + ] ) - let subRequestData: Data = try! JSONEncoder().encode(subRequest) - let subRequestString: String? = String(data: subRequestData, encoding: .utf8) + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) - expect(subRequestString) - .to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"bytes\":[1,2,3]}")) + expect(requestString) + .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") - ) + request = OpenGroupAPI.BatchRequest( + requests: [ + OpenGroupAPI.PreparedSendData( + request: Request( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [:], + body: TestType(stringValue: "testValue") + ), + urlRequest: URLRequest(url: URL(string: "https://www.oxen.io")!), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 + ) + ] ) - let subRequestData: Data = try! JSONEncoder().encode(subRequest) - let subRequestString: String? = String(data: subRequestData, encoding: .utf8) + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) - expect(subRequestString) - .to(equal("{\"path\":\"\\/batch\",\"method\":\"GET\",\"json\":{\"stringValue\":\"testValue\"}}")) + expect(requestString) + .to(equal("[{\"path\":\"\\/batch\",\"method\":\"GET\",\"json\":{\"stringValue\":\"testValue\"}}]")) + } + + it("strips authentication headers") { + let httpRequest: Request = Request( + method: .get, + server: "testServer", + endpoint: .batch, + queryParameters: [:], + headers: [ + "TestHeader": "Test", + HTTPHeader.sogsPubKey: "A", + HTTPHeader.sogsTimestamp: "B", + HTTPHeader.sogsNonce: "C", + HTTPHeader.sogsSignature: "D" + ], + body: nil + ) + request = OpenGroupAPI.BatchRequest( + requests: [ + OpenGroupAPI.PreparedSendData( + request: httpRequest, + urlRequest: try! httpRequest.generateUrlRequest(), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 + ) + ] + ) + + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) + + expect(requestString) + .toNot(contain([ + HTTPHeader.sogsPubKey, + HTTPHeader.sogsTimestamp, + HTTPHeader.sogsNonce, + HTTPHeader.sogsSignature + ])) } } - } - - // 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( + it("does not strip non authentication headers") { + let httpRequest: Request = Request( method: .get, server: "testServer", endpoint: .batch, queryParameters: [:], - headers: [:], - body: TestType(stringValue: "testValue") + headers: [ + "TestHeader": "Test", + HTTPHeader.sogsPubKey: "A", + HTTPHeader.sogsTimestamp: "B", + HTTPHeader.sogsNonce: "C", + HTTPHeader.sogsSignature: "D" + ], + body: nil ) - } - - 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 + request = OpenGroupAPI.BatchRequest( + requests: [ + OpenGroupAPI.PreparedSendData( + request: httpRequest, + urlRequest: try! httpRequest.generateUrlRequest(), + publicKey: "", + responseType: NoResponse.self, + timeout: 0 ) - ), - 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: []) + let requestData: Data = try! JSONEncoder().encode(request) + let requestString: String? = String(data: requestData, encoding: .utf8) - 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)) + expect(requestString) + .to(contain("\"TestHeader\":\"Test\"")) } } } diff --git a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift index 5225a130a..ea835122f 100644 --- a/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/OpenGroupSpec.swift @@ -93,18 +93,6 @@ class OpenGroupSpec: QuickSpec { expect(OpenGroup.idFor(roomToken: "RoOM", server: "server")).to(equal("server.RoOM")) } } - - context("when generating a url") { - it("generates the url correctly") { - expect(OpenGroup.urlFor(server: "server", roomToken: "room", publicKey: "key")) - .to(equal("server/room?public_key=key")) - } - - it("maintains the casing provided") { - expect(OpenGroup.urlFor(server: "SeRVer", roomToken: "RoOM", publicKey: "KEy")) - .to(equal("SeRVer/RoOM?public_key=KEy")) - } - } } } } diff --git a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift index 859cff307..176201e26 100644 --- a/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift +++ b/SessionMessagingKitTests/Open Groups/Models/SOGSMessageSpec.swift @@ -106,7 +106,7 @@ class SOGSMessageSpec: QuickSpec { expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) } - .to(throwError(HTTP.Error.parsingFailed)) + .to(throwError(HTTPError.parsingFailed)) } it("errors if the data is not a base64 encoded string") { @@ -128,7 +128,7 @@ class SOGSMessageSpec: QuickSpec { expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) } - .to(throwError(HTTP.Error.parsingFailed)) + .to(throwError(HTTPError.parsingFailed)) } it("errors if the signature is not a base64 encoded string") { @@ -150,7 +150,7 @@ class SOGSMessageSpec: QuickSpec { expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) } - .to(throwError(HTTP.Error.parsingFailed)) + .to(throwError(HTTPError.parsingFailed)) } it("errors if the dependencies are not provided to the JSONDecoder") { @@ -159,7 +159,7 @@ class SOGSMessageSpec: QuickSpec { expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) } - .to(throwError(HTTP.Error.parsingFailed)) + .to(throwError(HTTPError.parsingFailed)) } it("errors if the session_id value is not valid") { @@ -181,7 +181,7 @@ class SOGSMessageSpec: QuickSpec { expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) } - .to(throwError(HTTP.Error.parsingFailed)) + .to(throwError(HTTPError.parsingFailed)) } @@ -239,7 +239,7 @@ class SOGSMessageSpec: QuickSpec { expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) } - .to(throwError(HTTP.Error.parsingFailed)) + .to(throwError(HTTPError.parsingFailed)) } } @@ -274,7 +274,7 @@ class SOGSMessageSpec: QuickSpec { expect { try decoder.decode(OpenGroupAPI.Message.self, from: messageData) } - .to(throwError(HTTP.Error.parsingFailed)) + .to(throwError(HTTPError.parsingFailed)) } } } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 47e461442..d87bfdca3 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import PromiseKit +import Foundation +import Combine import GRDB import Sodium import SessionSnodeKit @@ -24,20 +25,21 @@ class OpenGroupAPISpec: QuickSpec { var mockNonce16Generator: MockNonce16Generator! var mockNonce24Generator: MockNonce24Generator! var dependencies: SMKDependencies! + var disposables: [AnyCancellable] = [] - var response: (OnionRequestResponseInfoType, Codable)? = nil - var pollResponse: [OpenGroupAPI.Endpoint: (OnionRequestResponseInfoType, Codable?)]? + var response: (ResponseInfoType, Codable)? = nil + var pollResponse: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)? var error: Error? - + describe("an OpenGroupAPI") { // MARK: - Configuration beforeEach { mockStorage = Storage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations(), - SNMessagingKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self, + SNMessagingKit.self ] ) mockSodium = MockSodium() @@ -87,7 +89,7 @@ class OpenGroupAPISpec: QuickSpec { mockSodium .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } .thenReturn( - Box.KeyPair( + KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) @@ -114,6 +116,8 @@ class OpenGroupAPISpec: QuickSpec { } afterEach { + disposables.forEach { $0.cancel() } + mockStorage = nil mockSodium = nil mockAeadXChaCha20Poly1305Ietf = nil @@ -121,6 +125,7 @@ class OpenGroupAPISpec: QuickSpec { mockGenericHash = nil mockEd25519 = nil dependencies = nil + disposables = [] response = nil pollResponse = nil @@ -136,7 +141,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), @@ -144,7 +149,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: try! JSONDecoder().decode( @@ -163,7 +168,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: [OpenGroupAPI.Message](), @@ -181,8 +186,8 @@ class OpenGroupAPISpec: QuickSpec { it("generates the correct request") { mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -190,9 +195,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -202,24 +208,22 @@ class OpenGroupAPISpec: QuickSpec { 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)) + expect(pollResponse?.data.count).to(equal(3)) + expect(pollResponse?.data.keys).to(contain(.capabilities)) + expect(pollResponse?.data.keys).to(contain(.roomPollInfo("testRoom", 0))) + expect(pollResponse?.data.keys).to(contain(.roomMessagesRecent("testRoom"))) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (pollResponse?[.capabilities]?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (pollResponse?.info 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( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -227,9 +231,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -237,7 +242,7 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) + expect(pollResponse?.data.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") { @@ -247,8 +252,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -256,9 +261,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -266,7 +272,7 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.roomMessagesRecent("testRoom"))) + expect(pollResponse?.data.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") { @@ -276,8 +282,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -285,9 +291,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -295,7 +302,7 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) + expect(pollResponse?.data.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") { @@ -305,8 +312,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -314,9 +321,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -324,7 +332,7 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) + expect(pollResponse?.data.keys).to(contain(.roomMessagesSince("testRoom", seqNo: 123))) } context("when unblinded") { @@ -337,8 +345,8 @@ class OpenGroupAPISpec: QuickSpec { it("does not call the inbox and outbox endpoints") { mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -346,9 +354,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -358,8 +367,8 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate the response data - expect(pollResponse?.keys).toNot(contain(.inbox)) - expect(pollResponse?.keys).toNot(contain(.outbox)) + expect(pollResponse?.data.keys).toNot(contain(.inbox)) + expect(pollResponse?.data.keys).toNot(contain(.outbox)) } } @@ -369,7 +378,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), @@ -377,7 +386,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: try! JSONDecoder().decode( @@ -396,7 +405,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: [OpenGroupAPI.Message](), @@ -404,7 +413,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: [OpenGroupAPI.DirectMessage](), @@ -412,7 +421,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: [OpenGroupAPI.DirectMessage](), @@ -436,8 +445,8 @@ class OpenGroupAPISpec: QuickSpec { it("includes the inbox and outbox endpoints") { mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -445,9 +454,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -457,14 +467,14 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate the response data - expect(pollResponse?.keys).to(contain(.inbox)) - expect(pollResponse?.keys).to(contain(.outbox)) + expect(pollResponse?.data.keys).to(contain(.inbox)) + expect(pollResponse?.data.keys).to(contain(.outbox)) } it("retrieves recent inbox messages if there was no last message") { mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -472,9 +482,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -482,7 +493,7 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.inbox)) + expect(pollResponse?.data.keys).to(contain(.inbox)) } it("retrieves inbox messages since the last message if there was one") { @@ -492,8 +503,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -501,9 +512,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -511,13 +523,13 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.inboxSince(id: 124))) + expect(pollResponse?.data.keys).to(contain(.inboxSince(id: 124))) } it("retrieves recent outbox messages if there was no last message") { mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -525,9 +537,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -535,7 +548,7 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.outbox)) + expect(pollResponse?.data.keys).to(contain(.outbox)) } it("retrieves outbox messages since the last message if there was one") { @@ -545,8 +558,8 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: true, @@ -554,9 +567,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -564,7 +578,7 @@ class OpenGroupAPISpec: QuickSpec { timeout: .milliseconds(100) ) expect(error?.localizedDescription).to(beNil()) - expect(pollResponse?.keys).to(contain(.outboxSince(id: 125))) + expect(pollResponse?.data.keys).to(contain(.outboxSince(id: 125))) } } } @@ -575,7 +589,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), @@ -583,7 +597,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), @@ -591,7 +605,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), @@ -606,8 +620,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -615,9 +629,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(pollResponse) .toEventuallyNot( @@ -626,9 +641,9 @@ class OpenGroupAPISpec: QuickSpec { ) 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]>) + let capabilitiesResponse: HTTP.BatchSubResponse? = (pollResponse?.data[.capabilities] as? HTTP.BatchSubResponse) + let pollInfoResponse: HTTP.BatchSubResponse? = (pollResponse?.data[.roomPollInfo("testRoom", 0)] as? HTTP.BatchSubResponse) + let messagesResponse: HTTP.BatchSubResponse<[Failable]>? = (pollResponse?.data[.roomMessagesRecent("testRoom")] as? HTTP.BatchSubResponse<[Failable]>) expect(capabilitiesResponse?.failedToParseBody).to(beFalse()) expect(pollInfoResponse?.failedToParseBody).to(beTrue()) expect(messagesResponse?.failedToParseBody).to(beTrue()) @@ -636,8 +651,8 @@ class OpenGroupAPISpec: QuickSpec { it("errors when no data is returned") { mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -645,13 +660,14 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + equal(HTTPError.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -665,8 +681,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -674,13 +690,14 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + equal(HTTPError.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -694,8 +711,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -703,13 +720,14 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + equal(HTTPError.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -723,8 +741,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -732,13 +750,14 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + equal(HTTPError.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -750,7 +769,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: OpenGroupAPI.Capabilities(capabilities: [], missing: nil), @@ -758,7 +777,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: try! JSONDecoder().decode( @@ -784,8 +803,8 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .read { db in - OpenGroupAPI.poll( + .readPublisher { db in + try OpenGroupAPI.preparedPoll( db, server: "testserver", hasPerformedInitialPoll: false, @@ -793,13 +812,14 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in pollResponse = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in pollResponse = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + equal(HTTPError.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -819,19 +839,20 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Capabilities)? mockStorage - .read { db in - OpenGroupAPI.capabilities( + .readPublisher { db in + try OpenGroupAPI.preparedCapabilities( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -846,7 +867,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/capabilities")) } } @@ -890,19 +910,20 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: (info: OnionRequestResponseInfoType, data: [OpenGroupAPI.Room])? + var response: (info: ResponseInfoType, data: [OpenGroupAPI.Room])? mockStorage - .read { db in - OpenGroupAPI.rooms( + .readPublisher { db in + try OpenGroupAPI.preparedRooms( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -917,7 +938,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/rooms")) } } @@ -960,7 +980,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: capabilitiesData, @@ -968,7 +988,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: roomData, @@ -982,20 +1002,21 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage - .read { db in - OpenGroupAPI.capabilitiesAndRoom( + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( db, for: "testRoom", on: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -1005,13 +1026,12 @@ class OpenGroupAPISpec: QuickSpec { 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)) + expect(response?.data.capabilities.data).to(equal(TestApi.capabilitiesData)) + expect(response?.data.room.data).to(equal(TestApi.roomData)) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.capabilities.info as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/sequence")) } } @@ -1024,7 +1044,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: capabilitiesData, @@ -1038,25 +1058,25 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage - .read { db in - OpenGroupAPI - .capabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + equal(HTTPError.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1096,7 +1116,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: roomData, @@ -1110,25 +1130,25 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage - .read { db in - OpenGroupAPI - .capabilitiesAndRoom( - db, - for: "testRoom", - on: "testserver", - using: dependencies - ) + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( + db, + for: "testRoom", + on: "testserver", + using: dependencies + ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + equal(HTTPError.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1169,7 +1189,7 @@ class OpenGroupAPISpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: capabilitiesData, @@ -1177,7 +1197,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: roomData, @@ -1185,7 +1205,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: OpenGroupAPI.PinnedMessage(id: 1, pinnedAt: 1, pinnedBy: ""), @@ -1199,24 +1219,25 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: (capabilities: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Capabilities?), room: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Room?))? + var response: (info: ResponseInfoType, data: OpenGroupAPI.CapabilitiesAndRoomResponse)? mockStorage - .read { db in - OpenGroupAPI.capabilitiesAndRoom( + .readPublisher { db in + try OpenGroupAPI.preparedCapabilitiesAndRoom( db, for: "testRoom", on: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + equal(HTTPError.parsingFailed.localizedDescription), timeout: .milliseconds(100) ) @@ -1258,12 +1279,12 @@ class OpenGroupAPISpec: QuickSpec { } it("correctly sends the message") { - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1274,9 +1295,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -1291,7 +1313,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testServer")) expect(requestData?.urlString).to(equal("testServer/room/testRoom/message")) } @@ -1304,12 +1325,12 @@ class OpenGroupAPISpec: QuickSpec { } it("signs the message correctly") { - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1320,9 +1341,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -1344,12 +1366,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try OpenGroup.deleteAll(db) } - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1360,9 +1382,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1379,12 +1402,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) } - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1395,9 +1418,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1412,12 +1436,12 @@ class OpenGroupAPISpec: QuickSpec { 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)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1428,9 +1452,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1452,12 +1477,12 @@ class OpenGroupAPISpec: QuickSpec { } it("signs the message correctly") { - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1468,9 +1493,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -1492,12 +1518,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try OpenGroup.deleteAll(db) } - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1508,9 +1534,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1527,12 +1554,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) } - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1543,9 +1570,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1568,12 +1596,12 @@ class OpenGroupAPISpec: QuickSpec { } .thenReturn(nil) - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, plaintext: "test".data(using: .utf8)!, to: "testRoom", @@ -1584,9 +1612,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1621,12 +1650,12 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.Message)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.Message)? mockStorage - .read { db in - OpenGroupAPI - .message( + .readPublisher { db in + try OpenGroupAPI + .preparedMessage( db, id: 123, in: "testRoom", @@ -1634,9 +1663,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -1651,7 +1681,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) } } @@ -1674,12 +1703,12 @@ class OpenGroupAPISpec: QuickSpec { } it("correctly sends the update") { - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1689,9 +1718,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -1703,7 +1733,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("PUT")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) } @@ -1716,12 +1745,12 @@ class OpenGroupAPISpec: QuickSpec { } it("signs the message correctly") { - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1731,9 +1760,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -1755,12 +1785,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try OpenGroup.deleteAll(db) } - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1770,9 +1800,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1789,12 +1820,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try Identity.filter(id: .x25519PrivateKey).deleteAll(db) } - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1804,9 +1835,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1821,12 +1853,12 @@ class OpenGroupAPISpec: QuickSpec { 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?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1836,9 +1868,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1860,12 +1893,12 @@ class OpenGroupAPISpec: QuickSpec { } it("signs the message correctly") { - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1875,9 +1908,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -1899,12 +1933,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try OpenGroup.deleteAll(db) } - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1914,9 +1948,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1933,12 +1968,12 @@ class OpenGroupAPISpec: QuickSpec { _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) } - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1948,9 +1983,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -1973,12 +2009,12 @@ class OpenGroupAPISpec: QuickSpec { } .thenReturn(nil) - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageUpdate( db, id: 123, plaintext: "test".data(using: .utf8)!, @@ -1988,9 +2024,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -2010,12 +2047,12 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .messageDelete( + .readPublisher { db in + try OpenGroupAPI + .preparedMessageDelete( db, id: 123, in: "testRoom", @@ -2023,9 +2060,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2037,13 +2075,12 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("DELETE")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/message/123")) } } context("when deleting all messages for a user") { - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? beforeEach { class TestApi: TestOnionRequestAPI { @@ -2058,9 +2095,9 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .read { db in - OpenGroupAPI - .messagesDeleteAll( + .readPublisher { db in + try OpenGroupAPI + .preparedMessagesDeleteAll( db, sessionId: "testUserId", in: "testRoom", @@ -2068,9 +2105,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2082,7 +2120,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("DELETE")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/all/testUserId")) } } @@ -2096,12 +2133,12 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: OnionRequestResponseInfoType? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .pinMessage( + .readPublisher { db in + try OpenGroupAPI + .preparedPinMessage( db, id: 123, in: "testRoom", @@ -2109,9 +2146,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2121,9 +2159,8 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/pin/123")) } } @@ -2135,12 +2172,12 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: OnionRequestResponseInfoType? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .unpinMessage( + .readPublisher { db in + try OpenGroupAPI + .preparedUnpinMessage( db, id: 123, in: "testRoom", @@ -2148,9 +2185,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2160,9 +2198,8 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/123")) } } @@ -2174,21 +2211,22 @@ class OpenGroupAPISpec: QuickSpec { } dependencies = dependencies.with(onionApi: TestApi.self) - var response: OnionRequestResponseInfoType? + var response: (info: ResponseInfoType, data: NoResponse)? mockStorage - .read { db in - OpenGroupAPI - .unpinAll( + .readPublisher { db in + try OpenGroupAPI + .preparedUnpinAll( db, in: "testRoom", on: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2198,9 +2236,8 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/unpin/all")) } } @@ -2217,9 +2254,9 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .read { db in - OpenGroupAPI - .uploadFile( + .readPublisher { db in + try OpenGroupAPI + .preparedUploadFile( db, bytes: [], to: "testRoom", @@ -2227,9 +2264,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2241,7 +2279,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/file")) } @@ -2254,9 +2291,9 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .read { db in - OpenGroupAPI - .uploadFile( + .readPublisher { db in + try OpenGroupAPI + .preparedUploadFile( db, bytes: [], to: "testRoom", @@ -2264,9 +2301,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2277,7 +2315,7 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.headers[Header.contentDisposition.rawValue]) + expect(requestData?.headers[HTTPHeader.contentDisposition]) .toNot(contain("filename")) } @@ -2290,9 +2328,9 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .read { db in - OpenGroupAPI - .uploadFile( + .readPublisher { db in + try OpenGroupAPI + .preparedUploadFile( db, bytes: [], fileName: "TestFileName", @@ -2301,9 +2339,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2314,7 +2353,7 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData - expect(requestData?.headers[Header.contentDisposition.rawValue]).to(contain("TestFileName")) + expect(requestData?.headers[HTTPHeader.contentDisposition]).to(contain("TestFileName")) } } @@ -2328,9 +2367,9 @@ class OpenGroupAPISpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockStorage - .read { db in - OpenGroupAPI - .downloadFile( + .readPublisher { db in + try OpenGroupAPI + .preparedDownloadFile( db, fileId: "1", from: "testRoom", @@ -2338,9 +2377,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2352,7 +2392,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("GET")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/room/testRoom/file/1")) } } @@ -2383,12 +2422,12 @@ class OpenGroupAPISpec: QuickSpec { } it("correctly sends the message request") { - var response: (info: OnionRequestResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? + var response: (info: ResponseInfoType, data: OpenGroupAPI.SendDirectMessageResponse)? mockStorage - .read { db in - OpenGroupAPI - .send( + .readPublisher { db in + try OpenGroupAPI + .preparedSend( db, ciphertext: "test".data(using: .utf8)!, toInboxFor: "testUserId", @@ -2396,9 +2435,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2413,7 +2453,6 @@ class OpenGroupAPISpec: QuickSpec { // Validate signature headers let requestData: TestOnionRequestAPI.RequestData? = (response?.0 as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/inbox/testUserId")) } } @@ -2421,7 +2460,7 @@ class OpenGroupAPISpec: QuickSpec { // MARK: - Users context("when banning a user") { - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? beforeEach { class TestApi: TestOnionRequestAPI { @@ -2436,9 +2475,9 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .read { db in - OpenGroupAPI - .userBan( + .readPublisher { db in + try OpenGroupAPI + .preparedUserBan( db, sessionId: "testUserId", for: nil, @@ -2447,9 +2486,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2461,15 +2501,14 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/user/testUserId/ban")) } it("does a global ban if no room tokens are provided") { mockStorage - .read { db in - OpenGroupAPI - .userBan( + .readPublisher { db in + try OpenGroupAPI + .preparedUserBan( db, sessionId: "testUserId", for: nil, @@ -2478,9 +2517,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2499,9 +2539,9 @@ class OpenGroupAPISpec: QuickSpec { it("does room specific bans if room tokens are provided") { mockStorage - .read { db in - OpenGroupAPI - .userBan( + .readPublisher { db in + try OpenGroupAPI + .preparedUserBan( db, sessionId: "testUserId", for: nil, @@ -2510,9 +2550,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2531,7 +2572,7 @@ class OpenGroupAPISpec: QuickSpec { } context("when unbanning a user") { - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? beforeEach { class TestApi: TestOnionRequestAPI { @@ -2546,9 +2587,9 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .read { db in - OpenGroupAPI - .userUnban( + .readPublisher { db in + try OpenGroupAPI + .preparedUserUnban( db, sessionId: "testUserId", from: nil, @@ -2556,9 +2597,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2570,15 +2612,14 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/user/testUserId/unban")) } it("does a global ban if no room tokens are provided") { mockStorage - .read { db in - OpenGroupAPI - .userUnban( + .readPublisher { db in + try OpenGroupAPI + .preparedUserUnban( db, sessionId: "testUserId", from: nil, @@ -2586,9 +2627,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2607,9 +2649,9 @@ class OpenGroupAPISpec: QuickSpec { it("does room specific bans if room tokens are provided") { mockStorage - .read { db in - OpenGroupAPI - .userUnban( + .readPublisher { db in + try OpenGroupAPI + .preparedUserUnban( db, sessionId: "testUserId", from: ["testRoom"], @@ -2617,9 +2659,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2638,7 +2681,7 @@ class OpenGroupAPISpec: QuickSpec { } context("when updating a users permissions") { - var response: (info: OnionRequestResponseInfoType, data: Data?)? + var response: (info: ResponseInfoType, data: NoResponse)? beforeEach { class TestApi: TestOnionRequestAPI { @@ -2653,9 +2696,9 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .read { db in - OpenGroupAPI - .userModeratorUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedUserModeratorUpdate( db, sessionId: "testUserId", moderator: true, @@ -2666,9 +2709,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2680,15 +2724,14 @@ class OpenGroupAPISpec: QuickSpec { // Validate request data let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/user/testUserId/moderator")) } it("does a global update if no room tokens are provided") { mockStorage - .read { db in - OpenGroupAPI - .userModeratorUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedUserModeratorUpdate( db, sessionId: "testUserId", moderator: true, @@ -2699,9 +2742,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2720,9 +2764,9 @@ class OpenGroupAPISpec: QuickSpec { it("does room specific updates if room tokens are provided") { mockStorage - .read { db in - OpenGroupAPI - .userModeratorUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedUserModeratorUpdate( db, sessionId: "testUserId", moderator: true, @@ -2733,9 +2777,10 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2754,9 +2799,9 @@ class OpenGroupAPISpec: QuickSpec { it("fails if neither moderator or admin are set") { mockStorage - .read { db in - OpenGroupAPI - .userModeratorUpdate( + .readPublisher { db in + try OpenGroupAPI + .preparedUserModeratorUpdate( db, sessionId: "testUserId", moderator: nil, @@ -2767,13 +2812,14 @@ class OpenGroupAPISpec: QuickSpec { using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.generic.localizedDescription), + equal(HTTPError.generic.localizedDescription), timeout: .milliseconds(100) ) @@ -2782,14 +2828,14 @@ class OpenGroupAPISpec: QuickSpec { } context("when banning and deleting all messages for a user") { - var response: [OnionRequestResponseInfoType]? + var response: (info: ResponseInfoType, data: OpenGroupAPI.BatchResponse)? beforeEach { class TestApi: TestOnionRequestAPI { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: nil, @@ -2797,7 +2843,7 @@ class OpenGroupAPISpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: nil, @@ -2818,19 +2864,19 @@ class OpenGroupAPISpec: QuickSpec { it("generates the request and handles the response correctly") { mockStorage - .read { db in - OpenGroupAPI - .userBanAndDeleteAllMessages( - db, - sessionId: "testUserId", - in: "testRoom", - on: "testserver", - using: dependencies - ) + .readPublisher { db in + try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2840,27 +2886,26 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.first as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData expect(requestData?.httpMethod).to(equal("POST")) - expect(requestData?.server).to(equal("testserver")) expect(requestData?.urlString).to(equal("testserver/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 - ) + .readPublisher { db in + try OpenGroupAPI.preparedUserBanAndDeleteAllMessages( + db, + sessionId: "testUserId", + in: "testRoom", + on: "testserver", + using: dependencies + ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -2870,7 +2915,7 @@ class OpenGroupAPISpec: QuickSpec { expect(error?.localizedDescription).to(beNil()) // Validate request data - let requestData: TestOnionRequestAPI.RequestData? = (response?.first as? TestOnionRequestAPI.ResponseInfo)?.requestData + let requestData: TestOnionRequestAPI.RequestData? = (response?.info as? TestOnionRequestAPI.ResponseInfo)?.requestData let jsonObject: Any = try! JSONSerialization.jsonObject( with: requestData!.body!, options: [.fragmentsAllowed] @@ -2905,17 +2950,18 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .read { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -2932,17 +2978,18 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .read { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -2959,17 +3006,18 @@ class OpenGroupAPISpec: QuickSpec { } mockStorage - .read { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -2990,17 +3038,18 @@ class OpenGroupAPISpec: QuickSpec { it("signs correctly") { mockStorage - .read { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -3013,31 +3062,31 @@ class OpenGroupAPISpec: QuickSpec { 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]) + expect(requestData?.headers[HTTPHeader.sogsPubKey]) .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())) + expect(requestData?.headers[HTTPHeader.sogsTimestamp]).to(equal("1234567890")) + expect(requestData?.headers[HTTPHeader.sogsNonce]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(requestData?.headers[HTTPHeader.sogsSignature]).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( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -3060,17 +3109,18 @@ class OpenGroupAPISpec: QuickSpec { it("signs correctly") { mockStorage - .read { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(response) .toEventuallyNot( @@ -3083,13 +3133,12 @@ class OpenGroupAPISpec: QuickSpec { 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())) + expect(requestData?.headers[HTTPHeader.sogsPubKey]).to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(requestData?.headers[HTTPHeader.sogsTimestamp]).to(equal("1234567890")) + expect(requestData?.headers[HTTPHeader.sogsNonce]).to(equal("pK6YRtQApl4NhECGizF0Cg==")) + expect(requestData?.headers[HTTPHeader.sogsSignature]).to(equal("TestSogsSignature".bytes.toBase64())) } it("fails when the blindedKeyPair is not generated") { @@ -3098,17 +3147,18 @@ class OpenGroupAPISpec: QuickSpec { .thenReturn(nil) mockStorage - .read { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( @@ -3125,17 +3175,18 @@ class OpenGroupAPISpec: QuickSpec { .thenReturn(nil) mockStorage - .read { db in - OpenGroupAPI - .rooms( + .readPublisher { db in + try OpenGroupAPI + .preparedRooms( db, server: "testserver", using: dependencies ) } - .get { result in response = result } - .catch { requestError in error = requestError } - .retainUntilComplete() + .flatMap { OpenGroupAPI.send(data: $0, using: dependencies) } + .handleEvents(receiveOutput: { result in response = result }) + .mapError { error.setting(to: $0) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index a8b3743e0..1a0ab6ace 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import PromiseKit +import Foundation +import Combine import GRDB import Sodium import SessionSnodeKit @@ -47,7 +48,7 @@ class OpenGroupManagerSpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: capabilitiesData, @@ -55,7 +56,7 @@ class OpenGroupManagerSpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: roomData, @@ -82,6 +83,7 @@ class OpenGroupManagerSpec: QuickSpec { var mockNonce24Generator: MockNonce24Generator! var mockUserDefaults: MockUserDefaults! var dependencies: OpenGroupManager.OGMDependencies! + var disposables: [AnyCancellable] = [] var testInteraction1: Interaction! var testGroupThread: SessionThread! @@ -101,9 +103,9 @@ class OpenGroupManagerSpec: QuickSpec { mockGeneralCache = MockGeneralCache() mockStorage = Storage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations(), - SNMessagingKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self, + SNMessagingKit.self ] ) mockSodium = MockSodium() @@ -114,9 +116,11 @@ class OpenGroupManagerSpec: QuickSpec { mockNonce24Generator = MockNonce24Generator() mockUserDefaults = MockUserDefaults() dependencies = OpenGroupManager.OGMDependencies( - cache: Atomic(mockOGMCache), + subscribeQueue: DispatchQueue.main, + receiveQueue: DispatchQueue.main, + cache: mockOGMCache, onionApi: TestCapabilitiesAndRoomApi.self, - generalCache: Atomic(mockGeneralCache), + generalCache: mockGeneralCache, storage: mockStorage, sodium: mockSodium, genericHash: mockGenericHash, @@ -150,7 +154,7 @@ class OpenGroupManagerSpec: QuickSpec { testGroupThread = SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), - variant: .openGroup + variant: .community ) testOpenGroup = OpenGroup( server: "testServer", @@ -234,9 +238,15 @@ class OpenGroupManagerSpec: QuickSpec { 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) } + .when { [mockGenericHash = mockGenericHash!] sodium in + sodium.blindedKeyPair( + serverPublicKey: any(), + edKeyPair: any(), + genericHash: mockGenericHash + ) + } .thenReturn( - Box.KeyPair( + KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) @@ -265,6 +275,8 @@ class OpenGroupManagerSpec: QuickSpec { } afterEach { + disposables.forEach { $0.cancel() } + OpenGroupManager.shared.stopPolling() // Need to stop any pollers which get created during tests openGroupManager.stopPolling() // Assuming it's different from the above @@ -276,6 +288,7 @@ class OpenGroupManagerSpec: QuickSpec { mockSign = nil mockUserDefaults = nil dependencies = nil + disposables = [] testInteraction1 = nil testGroupThread = nil @@ -289,7 +302,9 @@ class OpenGroupManagerSpec: QuickSpec { context("cache data") { it("defaults the time since last open to greatestFiniteMagnitude") { mockUserDefaults - .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) + } .thenReturn(nil) expect(cache.getTimeSinceLastOpen(using: dependencies)) @@ -298,7 +313,9 @@ class OpenGroupManagerSpec: QuickSpec { it("returns the time since the last open") { mockUserDefaults - .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) + } .thenReturn(Date(timeIntervalSince1970: 1234567880)) dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567890)) @@ -308,7 +325,9 @@ class OpenGroupManagerSpec: QuickSpec { it("caches the time since the last open") { mockUserDefaults - .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) + } .thenReturn(Date(timeIntervalSince1970: 1234567770)) dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567780)) @@ -316,7 +335,9 @@ class OpenGroupManagerSpec: QuickSpec { .to(beCloseTo(10)) mockUserDefaults - .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) + } .thenReturn(Date(timeIntervalSince1970: 1234567890)) // Cached value shouldn't have been updated @@ -346,12 +367,18 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.hasPerformedInitialPoll }.thenReturn([:]) mockOGMCache.when { $0.timeSinceLastPoll }.thenReturn([:]) - mockOGMCache.when { $0.getTimeSinceLastOpen(using: dependencies) }.thenReturn(0) + mockOGMCache + .when { [dependencies = dependencies!] cache in + cache.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) } + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) + } .thenReturn(Date(timeIntervalSince1970: 1234567890)) } @@ -359,18 +386,25 @@ class OpenGroupManagerSpec: QuickSpec { openGroupManager.startPolling(using: dependencies) expect(mockOGMCache) - .to(call(matchingParameters: true) { - $0.pollers = [ - "testserver": OpenGroupAPI.Poller(for: "testserver"), - "testserver1": OpenGroupAPI.Poller(for: "testserver1") - ] - }) + .toEventually( + call(matchingParameters: true) { + $0.pollers = [ + "testserver": OpenGroupAPI.Poller(for: "testserver"), + "testserver1": OpenGroupAPI.Poller(for: "testserver1") + ] + }, + timeout: .milliseconds(50) + ) } it("updates the isPolling flag") { openGroupManager.startPolling(using: dependencies) - expect(mockOGMCache).to(call(matchingParameters: true) { $0.isPolling = true }) + expect(mockOGMCache) + .toEventually( + call(matchingParameters: true) { $0.isPolling = true }, + timeout: .milliseconds(50) + ) } it("does nothing if already polling") { @@ -378,7 +412,10 @@ class OpenGroupManagerSpec: QuickSpec { openGroupManager.startPolling(using: dependencies) - expect(mockOGMCache).toNot(call { $0.pollers }) + expect(mockOGMCache).toEventuallyNot( + call { $0.pollers }, + timeout: .milliseconds(50) + ) } } @@ -401,12 +438,6 @@ class OpenGroupManagerSpec: QuickSpec { 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") { @@ -494,7 +525,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns true when no scheme is provided") { expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -509,7 +540,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns true when a http scheme is provided") { expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -524,7 +555,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns true when a https scheme is provided") { expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -545,7 +576,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns true when no scheme is provided") { expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -560,7 +591,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns true when a http scheme is provided") { expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -575,7 +606,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns true when a https scheme is provided") { expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -596,7 +627,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns true when no scheme is provided") { expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -611,7 +642,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns true when a http scheme is provided") { expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -626,7 +657,7 @@ class OpenGroupManagerSpec: QuickSpec { it("returns true when a https scheme is provided") { expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -647,7 +678,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://116.203.70.33"), - variant: .openGroup, + variant: .community, creationDateTimestamp: 0, shouldBeVisible: true, isPinned: false, @@ -659,7 +690,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -679,7 +710,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try SessionThread( id: OpenGroup.idFor(roomToken: "testRoom", server: "http://open.getsession.org"), - variant: .openGroup, + variant: .community, creationDateTimestamp: 0, shouldBeVisible: true, isPinned: false, @@ -691,7 +722,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -709,7 +740,7 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.pollers }.thenReturn(["testserver": OpenGroupAPI.Poller(for: "testserver")]) expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -726,7 +757,7 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.pollers }.thenReturn([:]) expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -746,7 +777,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db in + mockStorage.read { db -> Bool in openGroupManager .hasExistingOpenGroup( db, @@ -771,7 +802,9 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.pollers }.thenReturn([:]) mockUserDefaults - .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) + } .thenReturn(Date(timeIntervalSince1970: 1234567890)) } @@ -779,24 +812,34 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false // Prevent multi-threading test bugs mockStorage - .writeAsync { db in + .writePublisher { (db: Database) -> Bool in openGroupManager .add( db, roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - isConfigMessage: false, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic dependencies: dependencies ) } - .map { _ -> Void in didComplete = true } - .retainUntilComplete() + .flatMap { successfullyAddedGroup in + openGroupManager.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: "testRoom", + server: "testServer", + publicKey: TestConstants.serverPublicKey, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + dependencies: dependencies + ) + } + .handleEvents(receiveCompletion: { _ in didComplete = true }) + .sinkAndStore(in: &disposables) expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( mockStorage - .read { db in + .read { (db: Database) in try OpenGroup .select(.threadId) .asRequest(of: String.self) @@ -810,19 +853,29 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false // Prevent multi-threading test bugs mockStorage - .writeAsync { db in + .writePublisher { (db: Database) -> Bool in openGroupManager .add( db, roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - isConfigMessage: false, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic dependencies: dependencies ) } - .map { _ -> Void in didComplete = true } - .retainUntilComplete() + .flatMap { successfullyAddedGroup in + openGroupManager.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: "testRoom", + server: "testServer", + publicKey: TestConstants.serverPublicKey, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + dependencies: dependencies + ) + } + .handleEvents(receiveCompletion: { _ in didComplete = true }) + .sinkAndStore(in: &disposables) expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockOGMCache) @@ -847,7 +900,7 @@ class OpenGroupManagerSpec: QuickSpec { var didComplete: Bool = false // Prevent multi-threading test bugs mockStorage - .writeAsync { db in + .writePublisher { (db: Database) -> Bool in openGroupManager .add( db, @@ -856,12 +909,24 @@ class OpenGroupManagerSpec: QuickSpec { publicKey: TestConstants.serverPublicKey .replacingOccurrences(of: "c3", with: "00") .replacingOccurrences(of: "b3", with: "00"), - isConfigMessage: false, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic dependencies: dependencies ) } - .map { _ -> Void in didComplete = true } - .retainUntilComplete() + .flatMap { successfullyAddedGroup in + openGroupManager.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: "testRoom", + server: "testServer", + publicKey: TestConstants.serverPublicKey + .replacingOccurrences(of: "c3", with: "00") + .replacingOccurrences(of: "b3", with: "00"), + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + dependencies: dependencies + ) + } + .handleEvents(receiveCompletion: { _ in didComplete = true }) + .sinkAndStore(in: &disposables) expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( @@ -893,7 +958,9 @@ class OpenGroupManagerSpec: QuickSpec { dependencies = dependencies.with(onionApi: TestApi.self) mockUserDefaults - .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) + } .thenReturn(Date(timeIntervalSince1970: 1234567890)) } @@ -901,23 +968,33 @@ class OpenGroupManagerSpec: QuickSpec { var error: Error? mockStorage - .writeAsync { db in + .writePublisher { (db: Database) -> Bool in openGroupManager .add( db, roomToken: "testRoom", server: "testServer", publicKey: TestConstants.serverPublicKey, - isConfigMessage: false, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic dependencies: dependencies ) } - .catch { error = $0 } - .retainUntilComplete() + .flatMap { successfullyAddedGroup in + openGroupManager.performInitialRequestsAfterAdd( + successfullyAddedGroup: successfullyAddedGroup, + roomToken: "testRoom", + server: "testServer", + publicKey: TestConstants.serverPublicKey, + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + dependencies: dependencies + ) + } + .mapError { result -> Error in error.setting(to: result) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.parsingFailed.localizedDescription), + equal(HTTPError.parsingFailed.localizedDescription), timeout: .milliseconds(50) ) } @@ -952,7 +1029,8 @@ class OpenGroupManagerSpec: QuickSpec { .delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), - dependencies: dependencies + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + using: dependencies ) } @@ -966,11 +1044,12 @@ class OpenGroupManagerSpec: QuickSpec { .delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), - dependencies: dependencies + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + using: dependencies ) } - expect(mockStorage.read { db in try SessionThread.fetchCount(db) }) + expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }) .to(equal(0)) } @@ -983,7 +1062,8 @@ class OpenGroupManagerSpec: QuickSpec { .delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), - dependencies: dependencies + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + using: dependencies ) } @@ -996,7 +1076,8 @@ class OpenGroupManagerSpec: QuickSpec { .delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), - dependencies: dependencies + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + using: dependencies ) } @@ -1034,7 +1115,8 @@ class OpenGroupManagerSpec: QuickSpec { .delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: "testServer"), - dependencies: dependencies + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + using: dependencies ) } @@ -1086,7 +1168,8 @@ class OpenGroupManagerSpec: QuickSpec { .delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer), - dependencies: dependencies + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + using: dependencies ) } @@ -1100,7 +1183,8 @@ class OpenGroupManagerSpec: QuickSpec { .delete( db, openGroupId: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer), - dependencies: dependencies + calledFromConfigHandling: true, // Don't trigger SessionUtil logic + using: dependencies ) } @@ -1152,7 +1236,9 @@ class OpenGroupManagerSpec: QuickSpec { mockOGMCache.when { $0.pollers }.thenReturn([:]) mockUserDefaults - .when { $0.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) } + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: SNUserDefaults.Date.lastOpen.rawValue) + } .thenReturn(nil) } @@ -1242,8 +1328,10 @@ class OpenGroupManagerSpec: QuickSpec { ).insert(db) } - mockOGMCache.when { $0.groupImagePromises } - .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Promise.value(Data())]) + mockOGMCache.when { $0.groupImagePublishers } + .thenReturn([ + OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Just(Data()).setFailureType(to: Error.self).eraseToAnyPublisher() + ]) mockStorage.write { db in try OpenGroupManager.handlePollInfo( @@ -1303,7 +1391,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> GroupMember? in try GroupMember .filter(GroupMember.Columns.groupId == OpenGroup.idFor( roomToken: "testRoom", @@ -1362,7 +1450,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> GroupMember? in try GroupMember .filter(GroupMember.Columns.groupId == OpenGroup.idFor( roomToken: "testRoom", @@ -1415,7 +1503,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage.read { db in try GroupMember.fetchCount(db) }) + expect(mockStorage.read { db -> Int in try GroupMember.fetchCount(db) }) .to(equal(0)) } } @@ -1459,7 +1547,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> GroupMember? in try GroupMember .filter(GroupMember.Columns.groupId == OpenGroup.idFor( roomToken: "testRoom", @@ -1518,7 +1606,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> GroupMember? in try GroupMember .filter(GroupMember.Columns.groupId == OpenGroup.idFor( roomToken: "testRoom", @@ -1571,7 +1659,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) - expect(mockStorage.read { db in try GroupMember.fetchCount(db) }) + expect(mockStorage.read { db -> Int in try GroupMember.fetchCount(db) }) .to(equal(0)) } } @@ -1593,7 +1681,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try OpenGroup.fetchCount(db) }).to(equal(0)) + expect(mockStorage.read { db -> Int in try OpenGroup.fetchCount(db) }).to(equal(0)) } } @@ -1614,7 +1702,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> String? in try OpenGroup .select(.publicKey) .asRequest(of: String.self) @@ -1679,8 +1767,10 @@ class OpenGroupManagerSpec: QuickSpec { .updateAll(db, OpenGroup.Columns.imageData.set(to: nil)) } - mockOGMCache.when { $0.groupImagePromises } - .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Promise.value(imageData)]) + mockOGMCache.when { $0.groupImagePublishers } + .thenReturn([ + OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Just(imageData).setFailureType(to: Error.self).eraseToAnyPublisher() + ]) } it("uses the provided room image id if available") { @@ -1743,7 +1833,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> String? in try OpenGroup .select(.imageId) .asRequest(of: String.self) @@ -1751,7 +1841,7 @@ class OpenGroupManagerSpec: QuickSpec { } ).to(equal("10")) expect( - mockStorage.read { db in + mockStorage.read { db -> Data? in try OpenGroup .select(.imageData) .asRequest(of: Data.self) @@ -1809,7 +1899,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> String? in try OpenGroup .select(.imageId) .asRequest(of: String.self) @@ -1817,7 +1907,7 @@ class OpenGroupManagerSpec: QuickSpec { } ).to(equal("12")) expect( - mockStorage.read { db in + mockStorage.read { db -> Data? in try OpenGroup .select(.imageData) .asRequest(of: Data.self) @@ -1901,7 +1991,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> String? in try OpenGroup .select(.imageId) .asRequest(of: String.self) @@ -1909,7 +1999,7 @@ class OpenGroupManagerSpec: QuickSpec { } ).to(equal("10")) expect( - mockStorage.read { db in + mockStorage.read { db -> Data? in try OpenGroup .select(.imageData) .asRequest(of: Data.self) @@ -1918,7 +2008,7 @@ class OpenGroupManagerSpec: QuickSpec { ).toNot(beNil()) expect(mockOGMCache) .toEventually( - call(.exactly(times: 1)) { $0.groupImagePromises }, + call(.exactly(times: 1)) { $0.groupImagePublishers }, timeout: .milliseconds(50) ) } @@ -1940,7 +2030,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> Data? in try OpenGroup .select(.imageData) .asRequest(of: Data.self) @@ -1952,8 +2042,10 @@ class OpenGroupManagerSpec: QuickSpec { it("does nothing if it fails to retrieve the room image") { var didComplete: Bool = false // Prevent multi-threading test bugs - mockOGMCache.when { $0.groupImagePromises } - .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Promise(error: HTTP.Error.generic)]) + mockOGMCache.when { $0.groupImagePublishers } + .thenReturn([ + OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): Fail(error: HTTPError.generic).eraseToAnyPublisher() + ]) testPollInfo = OpenGroupAPI.RoomPollInfo( token: "testRoom", @@ -2012,7 +2104,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> Data? in try OpenGroup .select(.imageData) .asRequest(of: Data.self) @@ -2066,7 +2158,6 @@ class OpenGroupManagerSpec: QuickSpec { defaultUpload: nil ) ) - mockStorage.write { db in try OpenGroupManager.handlePollInfo( db, @@ -2081,7 +2172,7 @@ class OpenGroupManagerSpec: QuickSpec { expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> Data? in try OpenGroup .select(.imageData) .asRequest(of: Data.self) @@ -2130,7 +2221,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db in + mockStorage.read { db -> Int64? in try OpenGroup .select(.sequenceNumber) .asRequest(of: Int64.self) @@ -2151,7 +2242,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db in + mockStorage.read { db -> Int64? in try OpenGroup .select(.sequenceNumber) .asRequest(of: Int64.self) @@ -2190,7 +2281,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } it("ignores a message with invalid data") { @@ -2223,7 +2314,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } it("processes a message with valid data") { @@ -2237,7 +2328,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } it("processes valid messages when combined with invalid ones") { @@ -2267,7 +2358,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } context("with no data") { @@ -2305,7 +2396,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } it("does nothing if we do not have the message") { @@ -2334,7 +2425,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } } } @@ -2386,7 +2477,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db in + mockStorage.read { db -> Int64? in try OpenGroup .select(.inboxLatestMessageId) .asRequest(of: Int64.self) @@ -2394,7 +2485,7 @@ class OpenGroupManagerSpec: QuickSpec { } ).to(equal(0)) expect( - mockStorage.read { db in + mockStorage.read { db -> Int64? in try OpenGroup .select(.outboxLatestMessageId) .asRequest(of: Int64.self) @@ -2419,7 +2510,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db in + mockStorage.read { db -> Int64? in try OpenGroup .select(.inboxLatestMessageId) .asRequest(of: Int64.self) @@ -2427,7 +2518,7 @@ class OpenGroupManagerSpec: QuickSpec { } ).to(beNil()) expect( - mockStorage.read { db in + mockStorage.read { db -> Int64? in try OpenGroup .select(.outboxLatestMessageId) .asRequest(of: Int64.self) @@ -2456,7 +2547,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } context("for the inbox") { @@ -2482,7 +2573,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db in + mockStorage.read { db -> Int64? in try OpenGroup .select(.inboxLatestMessageId) .asRequest(of: Int64.self) @@ -2511,7 +2602,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(0)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(0)) } it("processes a message with valid data") { @@ -2525,7 +2616,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } it("processes valid messages when combined with invalid ones") { @@ -2549,7 +2640,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try Interaction.fetchCount(db) }).to(equal(1)) + expect(mockStorage.read { db -> Int in try Interaction.fetchCount(db) }).to(equal(1)) } } @@ -2576,7 +2667,7 @@ class OpenGroupManagerSpec: QuickSpec { } expect( - mockStorage.read { db in + mockStorage.read { db -> Int64? in try OpenGroup .select(.outboxLatestMessageId) .asRequest(of: Int64.self) @@ -2605,8 +2696,8 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try BlindedIdLookup.fetchCount(db) }).to(equal(1)) - expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(2)) + expect(mockStorage.read { db -> Int in try BlindedIdLookup.fetchCount(db) }).to(equal(1)) + expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(2)) } it("falls back to using the blinded id if no lookup is found") { @@ -2620,18 +2711,20 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try BlindedIdLookup.fetchCount(db) }).to(equal(1)) + expect(mockStorage.read { db -> Int in try BlindedIdLookup.fetchCount(db) }).to(equal(1)) expect(mockStorage - .read { db in + .read { db -> String? 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 -> Int in try SessionThread.fetchCount(db) }).to(equal(2)) expect( - mockStorage.read { db in try SessionThread.fetchOne(db, id: "15\(TestConstants.publicKey)") } + mockStorage.read { db -> SessionThread? in + try SessionThread.fetchOne(db, id: "15\(TestConstants.publicKey)") + } ).toNot(beNil()) } @@ -2655,7 +2748,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(1)) + expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(1)) } it("processes a message with valid data") { @@ -2669,7 +2762,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(2)) + expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(2)) } it("processes valid messages when combined with invalid ones") { @@ -2693,7 +2786,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } - expect(mockStorage.read { db in try SessionThread.fetchCount(db) }).to(equal(2)) + expect(mockStorage.read { db -> Int in try SessionThread.fetchCount(db) }).to(equal(2)) } } } @@ -2877,7 +2970,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .thenReturn( - Box.KeyPair( + KeyPair( publicKey: Data.data(fromHex: otherKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) @@ -2975,7 +3068,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .thenReturn( - Box.KeyPair( + KeyPair( publicKey: Data.data(fromHex: otherKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) @@ -3054,7 +3147,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .thenReturn( - Box.KeyPair( + KeyPair( publicKey: Data.data(fromHex: otherKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) @@ -3082,7 +3175,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .thenReturn( - Box.KeyPair( + KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) @@ -3121,7 +3214,7 @@ class OpenGroupManagerSpec: QuickSpec { ) } .thenReturn( - Box.KeyPair( + KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ) @@ -3194,7 +3287,7 @@ class OpenGroupManagerSpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: capabilitiesData, @@ -3202,7 +3295,7 @@ class OpenGroupManagerSpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: roomsData, @@ -3232,35 +3325,71 @@ class OpenGroupManagerSpec: QuickSpec { .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(()) + mockOGMCache.when { $0.defaultRoomsPublisher }.thenReturn(nil) + mockOGMCache.when { $0.groupImagePublishers }.thenReturn([:]) + mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: any()) + }.thenReturn(nil) + mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.set(anyAny(), forKey: any()) + }.thenReturn(()) } - it("caches the promise if there is no cached promise") { - let promise = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) + it("caches the publisher if there is no cached publisher") { + let publisher = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) expect(mockOGMCache) .to(call(matchingParameters: true) { - $0.defaultRoomsPromise = promise + $0.defaultRoomsPublisher = publisher }) } - it("returns the cached promise if there is one") { - let (promise, _) = Promise<[OpenGroupAPI.Room]>.pending() - mockOGMCache.when { $0.defaultRoomsPromise }.thenReturn(promise) + it("returns the cached publisher if there is one") { + let uniqueRoomInstance: OpenGroupAPI.Room = OpenGroupAPI.Room( + token: "UniqueId", + name: "", + 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 + ) + let publisher = Future<[OpenGroupManager.DefaultRoomInfo], Error> { resolver in + resolver(Result.success([(uniqueRoomInstance, nil)])) + } + .shareReplay(1) + .eraseToAnyPublisher() + mockOGMCache.when { $0.defaultRoomsPublisher }.thenReturn(publisher) + let publisher2 = OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - expect(OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies)) - .to(equal(promise)) + expect(publisher2.firstValue()?.map { $0.room }) + .to(equal(publisher.firstValue()?.map { $0.room })) } 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 -> Int in try OpenGroup.fetchCount(db) }).to(equal(1)) expect( - mockStorage.read { db in + mockStorage.read { db -> String? in try OpenGroup .select(.server) .asRequest(of: String.self) @@ -3268,7 +3397,7 @@ class OpenGroupManagerSpec: QuickSpec { } ).to(equal("https://open.getsession.org")) expect( - mockStorage.read { db in + mockStorage.read { db -> String? in try OpenGroup .select(.publicKey) .asRequest(of: String.self) @@ -3276,7 +3405,7 @@ class OpenGroupManagerSpec: QuickSpec { } ).to(equal("a03c383cf63c3c4efe67acc52112a6dd734b3a946b9545f488aaa93da7991238")) expect( - mockStorage.read { db in + mockStorage.read { db -> Bool? in try OpenGroup .select(.isActive) .asRequest(of: Bool.self) @@ -3286,13 +3415,13 @@ class OpenGroupManagerSpec: QuickSpec { } it("fetches rooms for the server") { - var response: [OpenGroupAPI.Room]? + var response: [OpenGroupManager.DefaultRoomInfo]? OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - .done { response = $0 } - .retainUntilComplete() + .handleEvents(receiveOutput: { response = $0 }) + .sinkAndStore(in: &disposables) - expect(response) + expect(response?.map { $0.room }) .toEventually( equal( [ @@ -3344,18 +3473,18 @@ class OpenGroupManagerSpec: QuickSpec { var error: Error? OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - .catch { error = $0 } - .retainUntilComplete() + .mapError { result -> Error in error.setting(to: result) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.invalidResponse.localizedDescription), + equal(HTTPError.invalidResponse.localizedDescription), timeout: .milliseconds(50) ) expect(TestRoomsApi.callCounter).to(equal(9)) // First attempt + 8 retries } - it("removes the cache promise if all retries fail") { + it("removes the cache publisher if all retries fail") { class TestRoomsApi: TestOnionRequestAPI { override class var mockResponse: Data? { return nil } } @@ -3364,17 +3493,17 @@ class OpenGroupManagerSpec: QuickSpec { var error: Error? OpenGroupManager.getDefaultRoomsIfNeeded(using: dependencies) - .catch { error = $0 } - .retainUntilComplete() + .mapError { result -> Error in error.setting(to: result) } + .sinkAndStore(in: &disposables) expect(error?.localizedDescription) .toEventually( - equal(HTTP.Error.invalidResponse.localizedDescription), + equal(HTTPError.invalidResponse.localizedDescription), timeout: .milliseconds(50) ) expect(mockOGMCache) .to(call(matchingParameters: true) { - $0.defaultRoomsPromise = nil + $0.defaultRoomsPublisher = nil }) } @@ -3414,7 +3543,7 @@ class OpenGroupManagerSpec: QuickSpec { override class var mockResponse: Data? { let responses: [Data] = [ try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: capabilitiesData, @@ -3422,7 +3551,7 @@ class OpenGroupManagerSpec: QuickSpec { ) ), try! JSONEncoder().encode( - OpenGroupAPI.BatchSubResponse( + HTTP.BatchSubResponse( code: 200, headers: [:], body: roomsData, @@ -3442,8 +3571,8 @@ class OpenGroupManagerSpec: QuickSpec { OpenGroupManager .getDefaultRoomsIfNeeded(using: dependencies) - .retainUntilComplete() - + .sinkAndStore(in: &disposables) + expect(mockUserDefaults) .toEventually( call(matchingParameters: true) { @@ -3452,10 +3581,10 @@ class OpenGroupManagerSpec: QuickSpec { forKey: SNUserDefaults.Date.lastOpenGroupImageUpdate.rawValue ) }, - timeout: .milliseconds(50) + timeout: .milliseconds(100) ) expect( - mockStorage.read { db in + mockStorage.read { db -> Data? in try OpenGroup .select(.imageData) .filter(id: OpenGroup.idFor(roomToken: "test2", server: OpenGroupAPI.defaultServer)) @@ -3475,9 +3604,13 @@ class OpenGroupManagerSpec: QuickSpec { } 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([:]) + mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: any()) + }.thenReturn(nil) + mockUserDefaults.when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.set(anyAny(), forKey: any()) + }.thenReturn(()) + mockOGMCache.when { $0.groupImagePublishers }.thenReturn([:]) mockStorage.write { db in _ = try OpenGroup( @@ -3493,41 +3626,47 @@ class OpenGroupManagerSpec: QuickSpec { } } - 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 - ) + it("retrieves the image retrieval publisher from the cache if it exists") { + let publisher = Future { resolver in + resolver(Result.success(Data([5, 4, 3, 2, 1]))) } - expect(promise2).to(equal(promise)) + .shareReplay(1) + .eraseToAnyPublisher() + mockOGMCache + .when { $0.groupImagePublishers } + .thenReturn([OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): publisher]) + + var result: Data? + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: "testServer", + existingData: nil, + using: dependencies + ) + .handleEvents(receiveOutput: { result = $0 }) + .sinkAndStore(in: &disposables) + + expect(result).toEventually(equal(publisher.firstValue()), timeout: .milliseconds(50)) } 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() + var didComplete: Bool = false + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: "testServer", + existingData: nil, + using: dependencies + ) + .handleEvents(receiveCompletion: { _ in didComplete = true }) + .sinkAndStore(in: &disposables) - expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> Data? in try OpenGroup .select(.imageData) .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: "testServer")) @@ -3541,19 +3680,19 @@ class OpenGroupManagerSpec: QuickSpec { } 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() + var didComplete: Bool = false + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: "testServer", + existingData: nil, + using: dependencies + ) + .handleEvents(receiveCompletion: { _ in didComplete = true }) + .sinkAndStore(in: &disposables) - expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockUserDefaults) .toEventuallyNot( call(matchingParameters: true) { @@ -3566,32 +3705,35 @@ class OpenGroupManagerSpec: QuickSpec { ) } - it("adds the image retrieval promise to the cache") { + it("adds the image retrieval publisher to the cache") { class TestNeverReturningApi: OnionRequestAPIType { - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String, timeout: TimeInterval) -> Promise<(OnionRequestResponseInfoType, Data?)> { - return Promise<(OnionRequestResponseInfoType, Data?)>.pending().promise + static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return Future<(ResponseInfoType, Data?), Error> { _ in }.eraseToAnyPublisher() } - static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?, timeout: TimeInterval) -> Promise { - return Promise.value(Data()) + static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return Just(Data()) + .setFailureType(to: Error.self) + .map { data in (HTTP.ResponseInfo(code: 0, headers: [:]), data) } + .eraseToAnyPublisher() } } dependencies = dependencies.with(onionApi: TestNeverReturningApi.self) - let promise = mockStorage.read { db in - OpenGroupManager.roomImage( - db, + let publisher = OpenGroupManager + .roomImage( fileId: "1", for: "testRoom", on: "testServer", + existingData: nil, using: dependencies ) - } + publisher.sinkAndStore(in: &disposables) expect(mockOGMCache) .toEventually( call(matchingParameters: true) { - $0.groupImagePromises = [OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): promise] + $0.groupImagePublishers = [OpenGroup.idFor(roomToken: "testRoom", server: "testServer"): publisher] }, timeout: .milliseconds(50) ) @@ -3601,40 +3743,37 @@ class OpenGroupManagerSpec: QuickSpec { 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() + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: nil, + using: dependencies + ) + .handleEvents(receiveOutput: { (data: Data) in result = data }) + .sinkAndStore(in: &disposables) - 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() + var didComplete: Bool = false - expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: nil, + using: dependencies + ) + .handleEvents(receiveCompletion: { _ in didComplete = true }) + .sinkAndStore(in: &disposables) + + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect( - mockStorage.read { db in + mockStorage.read { db -> Data? in try OpenGroup .select(.imageData) .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer)) @@ -3648,19 +3787,20 @@ class OpenGroupManagerSpec: QuickSpec { } 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() + var didComplete: Bool = false - expect(promise.isFulfilled).toEventually(beTrue(), timeout: .milliseconds(50)) + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: nil, + using: dependencies + ) + .handleEvents(receiveCompletion: { _ in didComplete = true }) + .sinkAndStore(in: &disposables) + + expect(didComplete).toEventually(beTrue(), timeout: .milliseconds(50)) expect(mockUserDefaults) .toEventually( call(matchingParameters: true) { @@ -3676,7 +3816,11 @@ class OpenGroupManagerSpec: QuickSpec { context("and there is a cached image") { beforeEach { dependencies = dependencies.with(date: Date(timeIntervalSince1970: 1234567890)) - mockUserDefaults.when { $0.object(forKey: any()) }.thenReturn(dependencies.date) + mockUserDefaults + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: any()) + } + .thenReturn(dependencies.date) mockStorage.write(updates: { db in try OpenGroup .filter(id: OpenGroup.idFor(roomToken: "testRoom", server: OpenGroupAPI.defaultServer)) @@ -3690,207 +3834,49 @@ class OpenGroupManagerSpec: QuickSpec { 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() + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: Data([2, 3, 4]), + using: dependencies + ) + .handleEvents(receiveOutput: { (data: Data) in result = data }) + .sinkAndStore(in: &disposables) - 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") { + let weekInSeconds: TimeInterval = (7 * 24 * 60 * 60) + let targetTimestamp: TimeInterval = ( + dependencies.date.timeIntervalSince1970 - weekInSeconds - 1 + ) mockUserDefaults - .when { $0.object(forKey: any()) } - .thenReturn( - Date(timeIntervalSince1970: - (dependencies.date.timeIntervalSince1970 - (7 * 24 * 60 * 60) - 1) - ) - ) + .when { (defaults: inout any UserDefaultsType) -> Any? in + defaults.object(forKey: any()) + } + .thenReturn(Date(timeIntervalSince1970: targetTimestamp)) 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() + OpenGroupManager + .roomImage( + fileId: "1", + for: "testRoom", + on: OpenGroupAPI.defaultServer, + existingData: Data([2, 3, 4]), + using: dependencies + ) + .handleEvents(receiveOutput: { (data: Data) in result = data }) + .sinkAndStore(in: &disposables) - 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")) - } - } } } } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift index 2f9e0e3f7..7f9a45a13 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverDecryptionSpec.swift @@ -27,9 +27,9 @@ class MessageReceiverDecryptionSpec: QuickSpec { beforeEach { mockStorage = Storage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations(), - SNMessagingKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self, + SNMessagingKit.self ] ) mockSodium = MockSodium() @@ -69,7 +69,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { mockSodium .when { $0.blindedKeyPair(serverPublicKey: any(), edKeyPair: any(), genericHash: mockGenericHash) } .thenReturn( - Box.KeyPair( + KeyPair( publicKey: Data(hex: TestConstants.blindedPublicKey).bytes, secretKey: Data(hex: TestConstants.edSecretKey).bytes ) @@ -113,7 +113,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" )!, - using: Box.KeyPair( + using: KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), @@ -139,7 +139,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { expect { try MessageReceiver.decryptWithSessionProtocol( ciphertext: "TestMessage".data(using: .utf8)!, - using: Box.KeyPair( + using: KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), @@ -163,7 +163,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { expect { try MessageReceiver.decryptWithSessionProtocol( ciphertext: "TestMessage".data(using: .utf8)!, - using: Box.KeyPair( + using: KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), @@ -181,7 +181,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { expect { try MessageReceiver.decryptWithSessionProtocol( ciphertext: "TestMessage".data(using: .utf8)!, - using: Box.KeyPair( + using: KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), @@ -197,7 +197,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { expect { try MessageReceiver.decryptWithSessionProtocol( ciphertext: "TestMessage".data(using: .utf8)!, - using: Box.KeyPair( + using: KeyPair( publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.privateKey)!.bytes ), @@ -219,7 +219,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -241,7 +241,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: false, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -260,7 +260,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -285,7 +285,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -318,7 +318,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -339,7 +339,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -364,7 +364,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -389,7 +389,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -414,7 +414,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -439,7 +439,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -464,7 +464,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), @@ -489,7 +489,7 @@ class MessageReceiverDecryptionSpec: QuickSpec { isOutgoing: true, otherBlindedPublicKey: "15\(TestConstants.blindedPublicKey)", with: TestConstants.serverPublicKey, - userEd25519KeyPair: Box.KeyPair( + userEd25519KeyPair: KeyPair( publicKey: Data.data(fromHex: TestConstants.edPublicKey)!.bytes, secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes ), diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift index aa3de4a23..f937b3744 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderEncryptionSpec.swift @@ -24,9 +24,9 @@ class MessageSenderEncryptionSpec: QuickSpec { beforeEach { mockStorage = Storage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations(), - SNMessagingKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self, + SNMessagingKit.self ] ) mockBox = MockBox() @@ -56,11 +56,14 @@ class MessageSenderEncryptionSpec: QuickSpec { } it("can encrypt correctly") { - let result = try? MessageSender.encryptWithSessionProtocol( - "TestMessage".data(using: .utf8)!, - for: "05\(TestConstants.publicKey)", - using: SMKDependencies(storage: mockStorage) - ) + let result = mockStorage.write { db in + try? MessageSender.encryptWithSessionProtocol( + db, + plaintext: "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()) @@ -68,11 +71,14 @@ class MessageSenderEncryptionSpec: QuickSpec { } it("returns the correct value when mocked") { - let result = try? MessageSender.encryptWithSessionProtocol( - "TestMessage".data(using: .utf8)!, - for: "05\(TestConstants.publicKey)", - using: dependencies - ) + let result = mockStorage.write { db in + try? MessageSender.encryptWithSessionProtocol( + db, + plaintext: "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: dependencies + ) + } expect(result?.bytes).to(equal([1, 2, 3])) } @@ -83,51 +89,63 @@ class MessageSenderEncryptionSpec: QuickSpec { _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) } - expect { - try MessageSender.encryptWithSessionProtocol( - "TestMessage".data(using: .utf8)!, - for: "05\(TestConstants.publicKey)", - using: dependencies - ) + mockStorage.write { db in + expect { + try MessageSender.encryptWithSessionProtocol( + db, + plaintext: "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: dependencies + ) + } + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } - .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 - ) + mockStorage.write { db in + expect { + try MessageSender.encryptWithSessionProtocol( + db, + plaintext: "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: dependencies + ) + } + .to(throwError(MessageSenderError.signingFailed)) } - .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 - ) + mockStorage.write { db in + expect { + try MessageSender.encryptWithSessionProtocol( + db, + plaintext: "TestMessage".data(using: .utf8)!, + for: "05\(TestConstants.publicKey)", + using: dependencies + ) + } + .to(throwError(MessageSenderError.encryptionFailed)) } - .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 - ) + let result = mockStorage.write { db in + try? MessageSender.encryptWithSessionBlindingProtocol( + db, + plaintext: "TestMessage".data(using: .utf8)!, + for: "15\(TestConstants.blindedPublicKey)", + openGroupPublicKey: TestConstants.serverPublicKey, + using: dependencies + ) + } expect(result?.toHexString()) .to(equal( @@ -138,23 +156,29 @@ class MessageSenderEncryptionSpec: QuickSpec { } 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 - ) + let result = mockStorage.write { db in + try? MessageSender.encryptWithSessionBlindingProtocol( + db, + plaintext: "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 maybeResult = mockStorage.write { db in + try? MessageSender.encryptWithSessionBlindingProtocol( + db, + plaintext: "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))..(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "is a Message This Test"), + TestMessage(body: "this message is a test"), + TestMessage(body: "This content is something which includes a combination of test words found in another message"), + TestMessage(body: "Do test messages contain content?"), + TestMessage(body: "Is messaging awesome?") + ])) + } + + // MARK: -- adds a wildcard to the final part + it("adds a wildcard to the final part") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "This mes", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "is a Message This Test"), + TestMessage(body: "this message is a test"), + TestMessage(body: "This content is something which includes a combination of test words found in another message"), + TestMessage(body: "Do test messages contain content?"), + TestMessage(body: "Is messaging awesome?") + ])) + } + + // MARK: -- does not add a wildcard to other parts + it("does not add a wildcard to other parts") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "mes Random", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(beEmpty()) + } + + // MARK: -- finds similar words without the wildcard due to the porter tokenizer + it("finds similar words without the wildcard due to the porter tokenizer") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "message z", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "is a Message This Test"), + TestMessage(body: "this message is a test"), + TestMessage( + body: "This content is something which includes a combination of test words found in another message" + ), + TestMessage(body: "Do test messages contain content?"), + TestMessage(body: "Is messaging awesome?") + ])) + } + + // MARK: -- finds results containing the words regardless of the order + it("finds results containing the words regardless of the order") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "is a message", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "is a Message This Test"), + TestMessage(body: "this message is a test"), + TestMessage( + body: "This content is something which includes a combination of test words found in another message" + ), + TestMessage(body: "Do test messages contain content?"), + TestMessage(body: "Is messaging awesome?") + ])) + } + + // MARK: -- does not find quoted parts out of order + it("does not find quoted parts out of order") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "\"this is a\" \"test message\"", + forTable: TestMessage.self + ) + + return try SQLRequest(literal: """ + SELECT * + FROM testMessage + JOIN testMessage_fts ON ( + testMessage_fts.rowId = testMessage.rowId AND + testMessage_fts.body MATCH \(pattern) + ) + """).fetchAll(db) + } + + expect(results) + .to(equal([ + TestMessage(body: "This is a Test Message"), + TestMessage(body: "Do test messages contain content?") + ])) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift b/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift index 5b7ef3fdb..99668e499 100644 --- a/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift +++ b/SessionMessagingKitTests/Utilities/SodiumUtilitiesSpec.swift @@ -2,6 +2,7 @@ import Foundation import Sodium +import SessionUtilitiesKit import Quick import Nimble @@ -86,7 +87,7 @@ class SodiumUtilitiesSpec: QuickSpec { it("successfully generates a blinded key pair") { let result = sodium.blindedKeyPair( serverPublicKey: TestConstants.serverPublicKey, - edKeyPair: Box.KeyPair( + edKeyPair: KeyPair( publicKey: Data(hex: TestConstants.edPublicKey).bytes, secretKey: Data(hex: TestConstants.edSecretKey).bytes ), @@ -102,7 +103,7 @@ class SodiumUtilitiesSpec: QuickSpec { it("fails if the edKeyPair public key length wrong") { let result = sodium.blindedKeyPair( serverPublicKey: TestConstants.serverPublicKey, - edKeyPair: Box.KeyPair( + edKeyPair: KeyPair( publicKey: Data(hex: String(TestConstants.edPublicKey.prefix(4))).bytes, secretKey: Data(hex: TestConstants.edSecretKey).bytes ), @@ -115,7 +116,7 @@ class SodiumUtilitiesSpec: QuickSpec { it("fails if the edKeyPair secret key length wrong") { let result = sodium.blindedKeyPair( serverPublicKey: TestConstants.serverPublicKey, - edKeyPair: Box.KeyPair( + edKeyPair: KeyPair( publicKey: Data(hex: TestConstants.edPublicKey).bytes, secretKey: Data(hex: String(TestConstants.edSecretKey.prefix(4))).bytes ), @@ -128,7 +129,7 @@ class SodiumUtilitiesSpec: QuickSpec { it("fails if it cannot generate a blinding factor") { let result = sodium.blindedKeyPair( serverPublicKey: "Test", - edKeyPair: Box.KeyPair( + edKeyPair: KeyPair( publicKey: Data(hex: TestConstants.edPublicKey).bytes, secretKey: Data(hex: TestConstants.edSecretKey).bytes ), diff --git a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift index 38f83c578..83e6af787 100644 --- a/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/DependencyExtensions.swift @@ -10,7 +10,7 @@ import SessionUtilitiesKit extension SMKDependencies { public func with( onionApi: OnionRequestAPIType.Type? = nil, - generalCache: Atomic? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, @@ -26,7 +26,7 @@ extension SMKDependencies { ) -> SMKDependencies { return SMKDependencies( onionApi: (onionApi ?? self._onionApi.wrappedValue), - generalCache: (generalCache ?? self._generalCache.wrappedValue), + generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue), storage: (storage ?? self._storage.wrappedValue), scheduler: (scheduler ?? self._scheduler.wrappedValue), sodium: (sodium ?? self._sodium.wrappedValue), diff --git a/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift index 09b0f9ce1..cb3888b59 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockAeadXChaCha20Poly1305Ietf.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit import Sodium @testable import SessionMessagingKit diff --git a/SessionMessagingKitTests/_TestUtilities/MockBox.swift b/SessionMessagingKitTests/_TestUtilities/MockBox.swift index ff8756977..3a991eec9 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockBox.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockBox.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit import Sodium @testable import SessionMessagingKit diff --git a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift index d92250663..259a18bfd 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockEd25519.swift @@ -1,13 +1,13 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit import Sodium +import SessionUtilitiesKit @testable import SessionMessagingKit class MockEd25519: Mock, Ed25519Type { - func sign(data: Bytes, keyPair: Box.KeyPair) throws -> Bytes? { + func sign(data: Bytes, keyPair: KeyPair) throws -> Bytes? { return accept(args: [data, keyPair]) as? Bytes } diff --git a/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift b/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift index 3a97611bf..f3eccdbc1 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockGenericHash.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit import Sodium @testable import SessionMessagingKit diff --git a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift index 02caa5e85..ec2b8ac10 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockOGMCache.swift @@ -1,19 +1,19 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit +import Combine import SessionUtilitiesKit @testable import SessionMessagingKit -class MockOGMCache: Mock, OGMCacheType { - var defaultRoomsPromise: Promise<[OpenGroupAPI.Room]>? { - get { return accept() as? Promise<[OpenGroupAPI.Room]> } +class MockOGMCache: Mock, OGMMutableCacheType { + var defaultRoomsPublisher: AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error>? { + get { return accept() as? AnyPublisher<[OpenGroupManager.DefaultRoomInfo], Error> } set { accept(args: [newValue]) } } - var groupImagePromises: [String: Promise] { - get { return accept() as! [String: Promise] } + var groupImagePublishers: [String: AnyPublisher] { + get { return accept() as! [String: AnyPublisher] } set { accept(args: [newValue]) } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockSign.swift b/SessionMessagingKitTests/_TestUtilities/MockSign.swift index 98f2887db..67a4ebe7f 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockSign.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockSign.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit import Sodium @testable import SessionMessagingKit diff --git a/SessionMessagingKitTests/_TestUtilities/MockSodium.swift b/SessionMessagingKitTests/_TestUtilities/MockSodium.swift index 4ed5bb75f..a679462e0 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockSodium.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockSodium.swift @@ -1,8 +1,8 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit import Sodium +import SessionUtilitiesKit @testable import SessionMessagingKit @@ -16,8 +16,8 @@ class MockSodium: Mock, SodiumType { 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 blindedKeyPair(serverPublicKey: String, edKeyPair: KeyPair, genericHash: GenericHashType) -> KeyPair? { + return accept(args: [serverPublicKey, edKeyPair, genericHash]) as? KeyPair } func sogsSignature(message: Bytes, secretKey: Bytes, blindedSecretKey ka: Bytes, blindedPublicKey kA: Bytes) -> Bytes? { diff --git a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift index b297e62a8..a2be81109 100644 --- a/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/OGMDependencyExtensions.swift @@ -9,9 +9,9 @@ import SessionUtilitiesKit extension OpenGroupManager.OGMDependencies { public func with( - cache: Atomic? = nil, + cache: OGMMutableCacheType? = nil, onionApi: OnionRequestAPIType.Type? = nil, - generalCache: Atomic? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, scheduler: ValueObservationScheduler? = nil, sodium: SodiumType? = nil, @@ -28,7 +28,7 @@ extension OpenGroupManager.OGMDependencies { return OpenGroupManager.OGMDependencies( cache: (cache ?? self._mutableCache.wrappedValue), onionApi: (onionApi ?? self._onionApi.wrappedValue), - generalCache: (generalCache ?? self._generalCache.wrappedValue), + generalCache: (generalCache ?? self._mutableGeneralCache.wrappedValue), storage: (storage ?? self._storage.wrappedValue), scheduler: (scheduler ?? self._scheduler.wrappedValue), sodium: (sodium ?? self._sodium.wrappedValue), diff --git a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift index 3da2fbdd4..89aab1217 100644 --- a/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift +++ b/SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift @@ -1,7 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import PromiseKit +import Combine import SessionSnodeKit import SessionUtilitiesKit @@ -13,14 +13,18 @@ class TestOnionRequestAPI: OnionRequestAPIType { let urlString: String? let httpMethod: String let headers: [String: String] - let snodeMethod: String? let body: Data? + let destination: OnionRequestAPIDestination - let server: String - let version: OnionRequestAPIVersion - let publicKey: String? + var publicKey: String? { + switch destination { + case .snode: return nil + case .server(_, _, let x25519PublicKey, _, _): return x25519PublicKey + } + } } - class ResponseInfo: OnionRequestResponseInfoType { + + class ResponseInfo: ResponseInfoType { let requestData: RequestData let code: Int let headers: [String: String] @@ -34,27 +38,45 @@ class TestOnionRequestAPI: OnionRequestAPIType { class var mockResponse: Data? { return nil } - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String, timeout: TimeInterval) -> Promise<(OnionRequestResponseInfoType, Data?)> { + static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { 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 + destination: OnionRequestAPIDestination.server( + host: (request.url?.host ?? ""), + target: OnionRequestAPIVersion.v4.rawValue, + x25519PublicKey: x25519PublicKey, + scheme: request.url!.scheme, + port: request.url!.port.map { UInt16($0) } + ) ), code: 200, headers: [:] ) - return Promise.value((responseInfo, mockResponse)) + return Just((responseInfo, mockResponse)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?, timeout: TimeInterval) -> Promise { - return Promise.value(mockResponse!) + static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + let responseInfo: ResponseInfo = ResponseInfo( + requestData: RequestData( + urlString: "\(snode.address):\(snode.port)/onion_req/v2", + httpMethod: "POST", + headers: [:], + body: payload, + destination: OnionRequestAPIDestination.snode(snode) + ), + code: 200, + headers: [:] + ) + + return Just((responseInfo, mockResponse)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index eb74f0f77..acff494bb 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -9,7 +9,7 @@ import SessionMessagingKit public class NSENotificationPresenter: NSObject, NotificationsProtocol { private var notifications: [String: UNNotificationRequest] = [:] - public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // Ensure we should be showing a notification for the thread @@ -26,7 +26,7 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { ) var notificationTitle: String = senderName - if thread.variant == .closedGroup || thread.variant == .openGroup { + if thread.variant == .legacyGroup || thread.variant == .group || thread.variant == .community { if thread.onlyNotifyForMentions && !interaction.hasMention { // Ignore PNs if the group is set to only notify for mentions return @@ -85,11 +85,11 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { // Add request (try to group notifications for interactions from open groups) let identifier: String = interaction.notificationIdentifier( - shouldGroupMessagesForThread: (thread.variant == .openGroup) + shouldGroupMessagesForThread: (thread.variant == .community) ) var trigger: UNNotificationTrigger? - if thread.variant == .openGroup { + if thread.variant == .community { trigger = UNTimeIntervalNotificationTrigger( timeInterval: Notifications.delayForGroupedNotifications, repeats: false @@ -124,10 +124,14 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { ) } - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { // No call notifications for muted or group threads guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + thread.variant != .legacyGroup && + thread.variant != .group && + thread.variant != .community + else { return } guard interaction.variant == .infoCall, let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), @@ -176,12 +180,16 @@ public class NSENotificationPresenter: NSObject, NotificationsProtocol { ) } - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) { let isMessageRequest: Bool = thread.isMessageRequest(db, includeNonVisible: true) // No reaction notifications for muted, group threads or message requests guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard thread.variant != .closedGroup && thread.variant != .openGroup else { return } + guard + thread.variant != .legacyGroup && + thread.variant != .group && + thread.variant != .community + else { return } guard !isMessageRequest else { return } let senderName: String = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant) diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 7cfeae747..2db3e27ba 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -1,17 +1,17 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import CallKit import UserNotifications import BackgroundTasks -import PromiseKit import SessionMessagingKit import SignalUtilitiesKit +import SignalCoreKit public final class NotificationServiceExtension: UNNotificationServiceExtension { private var didPerformSetup = false - private var areVersionMigrationsComplete = false private var contentHandler: ((UNNotificationContent) -> Void)? private var request: UNNotificationRequest? @@ -45,11 +45,17 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // Handle the push notification AppReadiness.runNowOrWhenAppDidBecomeReady { - let openGroupPollingPromises = self.pollForOpenGroups() + let openGroupPollingPublishers: [AnyPublisher] = self.pollForOpenGroups() defer { - when(resolved: openGroupPollingPromises).done { _ in - self.completeSilenty() - } + Publishers + .MergeMany(openGroupPollingPublishers) + .subscribe(on: DispatchQueue.global(qos: .background)) + .subscribe(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { _ in + self.completeSilenty() + } + ) } guard @@ -70,23 +76,22 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension return } - let maybeVariant: SessionThread.Variant? = processedMessage.threadId - .map { threadId in - try? SessionThread - .filter(id: threadId) - .select(.variant) - .asRequest(of: SessionThread.Variant.self) - .fetchOne(db) - } - let isOpenGroup: Bool = (maybeVariant == .openGroup) + // Throw if the message is outdated and shouldn't be processed + try MessageReceiver.throwIfMessageOutdated( + db, + message: processedMessage.messageInfo.message, + threadId: processedMessage.threadId, + threadVariant: processedMessage.threadVariant + ) switch processedMessage.messageInfo.message { case let visibleMessage as VisibleMessage: let interactionId: Int64 = try MessageReceiver.handleVisibleMessage( db, + threadId: processedMessage.threadId, + threadVariant: processedMessage.threadVariant, message: visibleMessage, - associatedWithProto: processedMessage.proto, - openGroupId: (isOpenGroup ? processedMessage.threadId : nil) + associatedWithProto: processedMessage.proto ) // Remove the notifications if there is an outgoing messages from a linked device @@ -106,26 +111,58 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } case let unsendRequest as UnsendRequest: - try MessageReceiver.handleUnsendRequest(db, message: unsendRequest) + try MessageReceiver.handleUnsendRequest( + db, + threadId: processedMessage.threadId, + threadVariant: processedMessage.threadVariant, + message: unsendRequest + ) case let closedGroupControlMessage as ClosedGroupControlMessage: - try MessageReceiver.handleClosedGroupControlMessage(db, closedGroupControlMessage) + try MessageReceiver.handleClosedGroupControlMessage( + db, + threadId: processedMessage.threadId, + threadVariant: processedMessage.threadVariant, + message: closedGroupControlMessage + ) case let callMessage as CallMessage: - try MessageReceiver.handleCallMessage(db, message: callMessage) + try MessageReceiver.handleCallMessage( + db, + threadId: processedMessage.threadId, + threadVariant: processedMessage.threadVariant, + 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( + if + let sender: String = callMessage.sender, + let interaction: Interaction = try MessageReceiver.insertCallInfoMessage( + db, + for: callMessage, + state: .permissionDenied + ) + { + let thread: SessionThread = try SessionThread + .fetchOrCreate( db, - forIncomingCall: interaction, - in: thread + id: sender, + variant: .contact, + shouldBeVisible: nil ) + + // Notify the user if the call message wasn't already read + if !interaction.wasRead { + Environment.shared?.notificationsManager.wrappedValue? + .notifyUser( + db, + forIncomingCall: interaction, + in: thread, + applicationState: .background + ) + } } break } @@ -137,20 +174,27 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension self.handleSuccessForIncomingCall(db, for: callMessage) + case let sharedConfigMessage as SharedConfigMessage: + try SessionUtil.handleConfigMessages( + db, + messages: [sharedConfigMessage], + publicKey: processedMessage.threadId + ) + default: break } // Perform any required post-handling logic try MessageReceiver.postHandleMessage( db, - message: processedMessage.messageInfo.message, - openGroupId: (isOpenGroup ? processedMessage.threadId : nil) + threadId: processedMessage.threadId, + message: processedMessage.messageInfo.message ) } catch { if let error = error as? MessageReceiverError, error.isRetryable { switch error { - case .invalidGroupPublicKey, .noGroupKeyPair: self.completeSilenty() + case .invalidGroupPublicKey, .noGroupKeyPair, .outdatedMessage: self.completeSilenty() default: self.handleFailure(for: notificationContent) } } @@ -191,38 +235,42 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension $0 = NSENotificationPresenter() } }, - migrationsCompletion: { [weak self] _, needsConfigSync in - self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) + migrationsCompletion: { [weak self] result, needsConfigSync in + switch result { + // Only 'NSLog' works in the extension - viewable via Console.app + case .failure: NSLog("[NotificationServiceExtension] Failed to complete migrations") + case .success: + DispatchQueue.main.async { + self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) + } + } + completion() } ) } - @objc private func versionMigrationsDidComplete(needsConfigSync: Bool) { AssertIsOnMainThread() - areVersionMigrationsComplete = true - // If we need a config sync then trigger it now if needsConfigSync { Storage.shared.write { db in - try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db)) } } - checkIsAppReady() + checkIsAppReady(migrationsCompleted: true) } - @objc - private func checkIsAppReady() { + private func checkIsAppReady(migrationsCompleted: Bool) { AssertIsOnMainThread() // Only mark the app as ready once. guard !AppReadiness.isAppReady() else { return } // App isn't ready until storage is ready AND all version migrations are complete. - guard Storage.shared.isValid && areVersionMigrationsComplete else { return } + guard Storage.shared.isValid && migrationsCompleted else { return } SignalUtilitiesKit.Configuration.performMainSetup() @@ -318,8 +366,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // MARK: - Poll for open groups - private func pollForOpenGroups() -> [Promise] { - let promises: [Promise] = Storage.shared + private func pollForOpenGroups() -> [AnyPublisher] { + return 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 @@ -332,16 +380,16 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension .fetchSet(db) } .defaulting(to: []) - .map { server in + .map { server -> AnyPublisher in OpenGroupAPI.Poller(for: server) .poll(calledFromBackgroundPoller: true, isPostCapabilitiesRetry: false) .timeout( - seconds: 20, - timeoutError: NotificationServiceError.timeout + .seconds(20), + scheduler: DispatchQueue.global(qos: .default), + customError: { NotificationServiceError.timeout } ) + .eraseToAnyPublisher() } - - return promises } private enum NotificationServiceError: Error { diff --git a/SessionShareExtension/Base.lproj/MainInterface.storyboard b/SessionShareExtension/Base.lproj/MainInterface.storyboard index b2f1bc5ef..d34ca84c3 100644 --- a/SessionShareExtension/Base.lproj/MainInterface.storyboard +++ b/SessionShareExtension/Base.lproj/MainInterface.storyboard @@ -1,17 +1,17 @@ - + - + - + - + diff --git a/SessionShareExtension/Meta/SessionShareExtension-Prefix.pch b/SessionShareExtension/Meta/SessionShareExtension-Prefix.pch deleted file mode 100644 index 1fa2513a1..000000000 --- a/SessionShareExtension/Meta/SessionShareExtension-Prefix.pch +++ /dev/null @@ -1,14 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -#ifdef __OBJC__ - #import - #import - - #import - #import - #import -#endif diff --git a/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h index b69a7ab21..216371943 100644 --- a/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h +++ b/SessionShareExtension/Meta/SignalShareExtension-Bridging-Header.h @@ -6,10 +6,6 @@ #import // Separate iOS Frameworks from other imports. -#import -#import -#import -#import #import #import #import diff --git a/SessionShareExtension/SAEScreenLockViewController.swift b/SessionShareExtension/SAEScreenLockViewController.swift index c0d0f9b0e..79bfc5a03 100644 --- a/SessionShareExtension/SAEScreenLockViewController.swift +++ b/SessionShareExtension/SAEScreenLockViewController.swift @@ -1,11 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import PromiseKit import SignalCoreKit import SignalUtilitiesKit import SessionUIKit import SessionUtilitiesKit +import SignalCoreKit final class SAEScreenLockViewController: ScreenLockViewController { private var hasShownAuthUIOnce: Bool = false diff --git a/SessionShareExtension/ShareAppExtensionContext.swift b/SessionShareExtension/ShareAppExtensionContext.swift index b06b701d1..4f3417642 100644 --- a/SessionShareExtension/ShareAppExtensionContext.swift +++ b/SessionShareExtension/ShareAppExtensionContext.swift @@ -4,6 +4,7 @@ import UIKit import SignalUtilitiesKit import SessionUtilitiesKit import SessionMessagingKit +import SignalCoreKit /// This is _NOT_ a singleton and will be instantiated each time that the SAE is used. final class ShareAppExtensionContext: NSObject, AppContext { diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareNavController.swift similarity index 58% rename from SessionShareExtension/ShareVC.swift rename to SessionShareExtension/ShareNavController.swift index 81baf46bc..457d5e277 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -1,15 +1,15 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import CoreServices -import PromiseKit import SignalUtilitiesKit import SessionUIKit import SessionUtilitiesKit +import SignalCoreKit -final class ShareVC: UINavigationController, ShareViewDelegate { - private var areVersionMigrationsComplete = false - public static var attachmentPrepPromise: Promise<[SignalAttachment]>? +final class ShareNavController: UINavigationController, ShareViewDelegate { + public static var attachmentPrepPublisher: AnyPublisher<[SignalAttachment], Error>? // MARK: - Error @@ -58,10 +58,16 @@ final class ShareVC: UINavigationController, ShareViewDelegate { $0 = NoopNotificationsManager() } }, - 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) + migrationsCompletion: { [weak self] result, needsConfigSync in + switch result { + case .failure: SNLog("[SessionShareExtension] Failed to complete migrations") + case .success: + DispatchQueue.main.async { + // performUpdateCheck must be invoked after Environment has been initialized because + // upgrade process may depend on Environment. + self?.versionMigrationsDidComplete(needsConfigSync: needsConfigSync) + } + } } ) @@ -82,30 +88,26 @@ final class ShareVC: UINavigationController, ShareViewDelegate { ThemeManager.traitCollectionDidChange(previousTraitCollection) } - @objc func versionMigrationsDidComplete(needsConfigSync: Bool) { AssertIsOnMainThread() Logger.debug("") - areVersionMigrationsComplete = true - // If we need a config sync then trigger it now if needsConfigSync { Storage.shared.write { db in - try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() + ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db)) } } - checkIsAppReady() + checkIsAppReady(migrationsCompleted: true) } - @objc - func checkIsAppReady() { + func checkIsAppReady(migrationsCompleted: Bool) { AssertIsOnMainThread() // App isn't ready until storage is ready AND all version migrations are complete. - guard areVersionMigrationsComplete else { return } + guard migrationsCompleted else { return } guard Storage.shared.isValid else { return } guard !AppReadiness.isAppReady() else { // Only mark the app as ready once. @@ -183,24 +185,25 @@ final class ShareVC: UINavigationController, ShareViewDelegate { private func showMainContent() { let threadPickerVC: ThreadPickerVC = ThreadPickerVC() - threadPickerVC.shareVC = self + threadPickerVC.shareNavController = self setViewControllers([ threadPickerVC ], animated: false) - let promise = buildAttachments() - ModalActivityIndicatorViewController.present( - fromViewController: self, - canCancel: false, - message: "vc_share_loading_message".localized()) { activityIndicator in - promise - .done { _ in - activityIndicator.dismiss { } - } - .catch { _ in - activityIndicator.dismiss { } - } - } - ShareVC.attachmentPrepPromise = promise + let publisher = buildAttachments() + ModalActivityIndicatorViewController + .present( + fromViewController: self, + canCancel: false, + message: "vc_share_loading_message".localized() + ) { activityIndicator in + publisher + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { _ in activityIndicator.dismiss { } } + ) + } + ShareNavController.attachmentPrepPublisher = publisher } func shareViewWasUnlocked() { @@ -365,10 +368,11 @@ final class ShareVC: UINavigationController, ShareViewDelegate { return [] } - private func selectItemProviders() -> Promise<[NSItemProvider]> { + private func selectItemProviders() -> AnyPublisher<[NSItemProvider], Error> { guard let inputItems = self.extensionContext?.inputItems else { let error = ShareViewControllerError.assertionError(description: "no input item") - return Promise(error: error) + return Fail(error: error) + .eraseToAnyPublisher() } for inputItemRaw in inputItems { @@ -377,12 +381,15 @@ final class ShareVC: UINavigationController, ShareViewDelegate { continue } - if let itemProviders = ShareVC.preferredItemProviders(inputItem: inputItem) { - return Promise.value(itemProviders) + if let itemProviders = ShareNavController.preferredItemProviders(inputItem: inputItem) { + return Just(itemProviders) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } } let error = ShareViewControllerError.assertionError(description: "no input item") - return Promise(error: error) + return Fail(error: error) + .eraseToAnyPublisher() } // MARK: - LoadedItem @@ -412,7 +419,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate { } } - private func loadItemProvider(itemProvider: NSItemProvider) -> Promise { + private func loadItemProvider(itemProvider: NSItemProvider) -> AnyPublisher { Logger.info("attachment: \(itemProvider)") // We need to be very careful about which UTI type we use. @@ -424,117 +431,177 @@ final class ShareVC: UINavigationController, ShareViewDelegate { // * UTIs aren't very descriptive (there are far more MIME types than UTI types) // so in the case of file attachments we try to refine the attachment type // using the file extension. - guard let srcUtiType = ShareVC.utiType(itemProvider: itemProvider) else { + guard let srcUtiType = ShareNavController.utiType(itemProvider: itemProvider) else { let error = ShareViewControllerError.unsupportedMedia - return Promise(error: error) + return Fail(error: error) + .eraseToAnyPublisher() } Logger.debug("matched utiType: \(srcUtiType)") - let (promise, resolver) = Promise.pending() - - let loadCompletion: NSItemProvider.CompletionHandler = { [weak self] - (value, error) in - - guard let _ = self else { return } - guard error == nil else { - resolver.reject(error!) - return - } - - guard let value = value else { - let missingProviderError = ShareViewControllerError.assertionError(description: "missing item provider") - resolver.reject(missingProviderError) - return - } - - Logger.info("value type: \(type(of: value))") - - if let data = value as? Data { - let customFileName = "Contact.vcf" - - let customFileExtension = MIMETypeUtil.fileExtension(forUTIType: srcUtiType) - guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: customFileExtension) else { - let writeError = ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))") - resolver.reject(writeError) - return - } - let fileUrl = URL(fileURLWithPath: tempFilePath) - resolver.fulfill(LoadedItem(itemProvider: itemProvider, + return Deferred { + Future { resolver in + let loadCompletion: NSItemProvider.CompletionHandler = { [weak self] value, error in + guard self != nil else { return } + if let error: Error = error { + resolver(Result.failure(error)) + return + } + + guard let value = value else { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "missing item provider")) + ) + return + } + + Logger.info("value type: \(type(of: value))") + + switch value { + case let data as Data: + let customFileName = "Contact.vcf" + + let customFileExtension = MIMETypeUtil.fileExtension(forUTIType: srcUtiType) + guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: customFileExtension) else { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) + ) + return + } + let fileUrl = URL(fileURLWithPath: tempFilePath) + + resolver( + Result.success( + LoadedItem( + itemProvider: itemProvider, + itemUrl: fileUrl, + utiType: srcUtiType, + customFileName: customFileName, + isConvertibleToContactShare: false + ) + ) + ) + + case let string as String: + Logger.debug("string provider: \(string)") + guard let data = string.filterStringForDisplay().data(using: String.Encoding.utf8) else { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) + ) + return + } + guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: "txt") else { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")) + ) + return + } + + let fileUrl = URL(fileURLWithPath: tempFilePath) + + let isConvertibleToTextMessage = !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String) + + if UTTypeConformsTo(srcUtiType as CFString, kUTTypeText) { + resolver( + Result.success( + LoadedItem( + itemProvider: itemProvider, itemUrl: fileUrl, utiType: srcUtiType, - customFileName: customFileName, - isConvertibleToContactShare: false)) - } else if let string = value as? String { - Logger.debug("string provider: \(string)") - guard let data = string.filterStringForDisplay().data(using: String.Encoding.utf8) else { - let writeError = ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))") - resolver.reject(writeError) - return - } - guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: "txt") else { - let writeError = ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))") - resolver.reject(writeError) - return - } - - let fileUrl = URL(fileURLWithPath: tempFilePath) - - let isConvertibleToTextMessage = !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String) - - if UTTypeConformsTo(srcUtiType as CFString, kUTTypeText) { - resolver.fulfill(LoadedItem(itemProvider: itemProvider, - itemUrl: fileUrl, - utiType: srcUtiType, - isConvertibleToTextMessage: isConvertibleToTextMessage)) - } else { - resolver.fulfill(LoadedItem(itemProvider: itemProvider, - itemUrl: fileUrl, - utiType: kUTTypeText as String, - isConvertibleToTextMessage: isConvertibleToTextMessage)) - } - } else if let url = value as? URL { - // If the share itself is a URL (e.g. a link from Safari), try to send this as a text message. - let isConvertibleToTextMessage = (itemProvider.registeredTypeIdentifiers.contains(kUTTypeURL as String) && - !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String)) - if isConvertibleToTextMessage { - resolver.fulfill(LoadedItem(itemProvider: itemProvider, + isConvertibleToTextMessage: isConvertibleToTextMessage + ) + ) + ) + } + else { + resolver( + Result.success( + LoadedItem( + itemProvider: itemProvider, + itemUrl: fileUrl, + utiType: kUTTypeText as String, + isConvertibleToTextMessage: isConvertibleToTextMessage + ) + ) + ) + } + + case let url as URL: + // If the share itself is a URL (e.g. a link from Safari), try to send this as a text message. + let isConvertibleToTextMessage = ( + itemProvider.registeredTypeIdentifiers.contains(kUTTypeURL as String) && + !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String) + ) + + if isConvertibleToTextMessage { + resolver( + Result.success( + LoadedItem( + itemProvider: itemProvider, + itemUrl: url, + utiType: kUTTypeURL as String, + isConvertibleToTextMessage: isConvertibleToTextMessage + ) + ) + ) + } + else { + resolver( + Result.success( + LoadedItem( + itemProvider: itemProvider, + itemUrl: url, + utiType: srcUtiType, + isConvertibleToTextMessage: isConvertibleToTextMessage + ) + ) + ) + } + + case let image as UIImage: + if let data = image.pngData() { + let tempFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: "png") + do { + let url = NSURL.fileURL(withPath: tempFilePath) + try data.write(to: url) + + resolver( + Result.success( + LoadedItem( + itemProvider: itemProvider, itemUrl: url, - utiType: kUTTypeURL as String, - isConvertibleToTextMessage: isConvertibleToTextMessage)) - } else { - resolver.fulfill(LoadedItem(itemProvider: itemProvider, - itemUrl: url, - utiType: srcUtiType, - isConvertibleToTextMessage: isConvertibleToTextMessage)) - } - } else if let image = value as? UIImage { - if let data = image.pngData() { - let tempFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: "png") - do { - let url = NSURL.fileURL(withPath: tempFilePath) - try data.write(to: url) - resolver.fulfill(LoadedItem(itemProvider: itemProvider, itemUrl: url, - utiType: srcUtiType)) - } catch { - resolver.reject(ShareViewControllerError.assertionError(description: "couldn't write UIImage: \(String(describing: error))")) + utiType: srcUtiType + ) + ) + ) + } + catch { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "couldn't write UIImage: \(String(describing: error))")) + ) + } + } + else { + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "couldn't convert UIImage to PNG: \(String(describing: error))")) + ) + } + + default: + // It's unavoidable that we may sometimes receives data types that we + // don't know how to handle. + resolver( + Result.failure(ShareViewControllerError.assertionError(description: "unexpected value: \(String(describing: value))")) + ) } - } else { - resolver.reject(ShareViewControllerError.assertionError(description: "couldn't convert UIImage to PNG: \(String(describing: error))")) } - } else { - // It's unavoidable that we may sometimes receives data types that we - // don't know how to handle. - let unexpectedTypeError = ShareViewControllerError.assertionError(description: "unexpected value: \(String(describing: value))") - resolver.reject(unexpectedTypeError) + + itemProvider.loadItem(forTypeIdentifier: srcUtiType, options: nil, completionHandler: loadCompletion) } } - - itemProvider.loadItem(forTypeIdentifier: srcUtiType, options: nil, completionHandler: loadCompletion) - - return promise + .eraseToAnyPublisher() } - private func buildAttachment(forLoadedItem loadedItem: LoadedItem) -> Promise { + private func buildAttachment(forLoadedItem loadedItem: LoadedItem) -> AnyPublisher { let itemProvider = loadedItem.itemProvider let itemUrl = loadedItem.itemUrl let utiType = loadedItem.utiType @@ -546,14 +613,16 @@ final class ShareVC: UINavigationController, ShareViewDelegate { } } catch { let error = ShareViewControllerError.assertionError(description: "Could not copy video") - return Promise(error: error) + return Fail(error: error) + .eraseToAnyPublisher() } Logger.debug("building DataSource with url: \(url), utiType: \(utiType)") - guard let dataSource = ShareVC.createDataSource(utiType: utiType, url: url, customFileName: loadedItem.customFileName) else { + guard let dataSource = ShareNavController.createDataSource(utiType: utiType, url: url, customFileName: loadedItem.customFileName) else { let error = ShareViewControllerError.assertionError(description: "Unable to read attachment data") - return Promise(error: error) + return Fail(error: error) + .eraseToAnyPublisher() } // start with base utiType, but it might be something generic like "image" @@ -572,8 +641,8 @@ final class ShareVC: UINavigationController, ShareViewDelegate { guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, dataUTI: specificUTIType) else { // This can happen, e.g. when sharing a quicktime-video from iCloud drive. - let (promise, _) = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: specificUTIType) - return promise + let (publisher, _) = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: specificUTIType) + return publisher } let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: specificUTIType, imageQuality: .medium) @@ -584,34 +653,44 @@ final class ShareVC: UINavigationController, ShareViewDelegate { Logger.info("isConvertibleToTextMessage") attachment.isConvertibleToTextMessage = true } - return Promise.value(attachment) + return Just(attachment) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - private func buildAttachments() -> Promise<[SignalAttachment]> { - return selectItemProviders().then { [weak self] (itemProviders) -> Promise<[SignalAttachment]> in - guard let strongSelf = self else { - let error = ShareViewControllerError.assertionError(description: "expired") - return Promise(error: error) - } + private func buildAttachments() -> AnyPublisher<[SignalAttachment], Error> { + return selectItemProviders() + .tryFlatMap { [weak self] itemProviders -> AnyPublisher<[SignalAttachment], Error> in + guard let strongSelf = self else { + throw ShareViewControllerError.assertionError(description: "expired") + } - var loadPromises = [Promise]() + var loadPublishers = [AnyPublisher]() - for itemProvider in itemProviders.prefix(SignalAttachment.maxAttachmentsAllowed) { - let loadPromise = strongSelf.loadItemProvider(itemProvider: itemProvider) - .then({ (loadedItem) -> Promise in - return strongSelf.buildAttachment(forLoadedItem: loadedItem) - }) + for itemProvider in itemProviders.prefix(SignalAttachment.maxAttachmentsAllowed) { + let loadPublisher = strongSelf.loadItemProvider(itemProvider: itemProvider) + .flatMap { loadedItem -> AnyPublisher in + return strongSelf.buildAttachment(forLoadedItem: loadedItem) + } + .eraseToAnyPublisher() - loadPromises.append(loadPromise) + loadPublishers.append(loadPublisher) + } + + return Publishers + .MergeMany(loadPublishers) + .collect() + .eraseToAnyPublisher() } - return when(fulfilled: loadPromises) - }.map { (signalAttachments) -> [SignalAttachment] in - guard signalAttachments.count > 0 else { - let error = ShareViewControllerError.assertionError(description: "no valid attachments") - throw error + .tryMap { signalAttachments -> [SignalAttachment] in + guard signalAttachments.count > 0 else { + throw ShareViewControllerError.assertionError(description: "no valid attachments") + } + + return signalAttachments } - return signalAttachments - } + .shareReplay(1) + .eraseToAnyPublisher() } // Some host apps (e.g. iOS Photos.app) sometimes auto-converts some video formats (e.g. com.apple.quicktime-movie) diff --git a/SessionShareExtension/SimplifiedConversationCell.swift b/SessionShareExtension/SimplifiedConversationCell.swift index 3c2fa1e32..3a561c433 100644 --- a/SessionShareExtension/SimplifiedConversationCell.swift +++ b/SessionShareExtension/SimplifiedConversationCell.swift @@ -39,7 +39,7 @@ final class SimplifiedConversationCell: UITableViewCell { }() private lazy var profilePictureView: ProfilePictureView = { - let view: ProfilePictureView = ProfilePictureView() + let view: ProfilePictureView = ProfilePictureView(size: .list) view.translatesAutoresizingMaskIntoConstraints = false return view @@ -79,10 +79,6 @@ final class SimplifiedConversationCell: UITableViewCell { accentLineView.set(.width, to: Values.accentLineThickness) accentLineView.set(.height, to: 68) - profilePictureView.set(.width, to: Values.mediumProfilePictureSize) - profilePictureView.set(.height, to: Values.mediumProfilePictureSize) - profilePictureView.size = Values.mediumProfilePictureSize - stackView.pin(to: self) } @@ -92,12 +88,10 @@ final class SimplifiedConversationCell: UITableViewCell { accentLineView.alpha = (cellViewModel.threadIsBlocked == true ? 1 : 0) profilePictureView.update( publicKey: cellViewModel.threadId, - profile: cellViewModel.profile, - additionalProfile: cellViewModel.additionalProfile, threadVariant: cellViewModel.threadVariant, - openGroupProfilePictureData: cellViewModel.openGroupProfilePictureData, - useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil), - showMultiAvatarForClosedGroup: true + customImageData: cellViewModel.openGroupProfilePictureData, + profile: cellViewModel.profile, + additionalProfile: cellViewModel.additionalProfile ) displayNameLabel.text = cellViewModel.displayName } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 77fa71796..4728da4d3 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -1,20 +1,21 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import Combine import GRDB -import PromiseKit import DifferenceKit -import Sodium import SessionUIKit import SignalUtilitiesKit import SessionMessagingKit final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableViewDelegate, AttachmentApprovalViewControllerDelegate { private let viewModel: ThreadPickerViewModel = ThreadPickerViewModel() - private var dataChangeObservable: DatabaseCancellable? + private var dataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } private var hasLoadedInitialData: Bool = false - var shareVC: ShareVC? + var shareNavController: ShareNavController? // MARK: - Intialization @@ -80,8 +81,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } @objc func applicationDidBecomeActive(_ notification: Notification) { @@ -92,8 +92,7 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView } @objc func applicationDidResignActive(_ notification: Notification) { - // Stop observing database changes - dataChangeObservable?.cancel() + stopObservingChanges() } // MARK: Layout @@ -105,6 +104,8 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // MARK: - Updating private func startObservingChanges() { + guard dataChangeObservable == nil else { return } + // Start observing for data changes dataChangeObservable = Storage.shared.start( viewModel.observableViewData, @@ -116,6 +117,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView ) } + private func stopObservingChanges() { + dataChangeObservable = nil + } + private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) { // Ensure the first load runs without animations (if we don't do this the cells will animate // in from a frame of CGRect.zero) @@ -153,14 +158,21 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - guard let attachments: [SignalAttachment] = ShareVC.attachmentPrepPromise?.value else { return } - - let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController( - threadId: self.viewModel.viewData[indexPath.row].threadId, - attachments: attachments, - approvalDelegate: self - ) - self.navigationController?.present(approvalVC, animated: true, completion: nil) + ShareNavController.attachmentPrepPublisher? + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveValue: { [weak self] attachments in + guard let strongSelf = self else { return } + + let approvalVC: UINavigationController = AttachmentApprovalViewController.wrappedInNavController( + threadId: strongSelf.viewModel.viewData[indexPath.row].threadId, + attachments: attachments, + approvalDelegate: strongSelf + ) + strongSelf.navigationController?.present(approvalVC, animated: true, completion: nil) + } + ) } func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?) { @@ -180,18 +192,21 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView messageText ) - shareVC?.dismiss(animated: true, completion: nil) + shareNavController?.dismiss(animated: true, completion: nil) - ModalActivityIndicatorViewController.present(fromViewController: shareVC!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in + ModalActivityIndicatorViewController.present(fromViewController: shareNavController!, canCancel: false, message: "vc_share_sending_message".localized()) { activityIndicator in // Resume database NotificationCenter.default.post(name: Database.resumeNotification, object: self) + 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) - } + .writePublisher { db -> MessageSender.PreparedSendData in + guard + let threadVariant: SessionThread.Variant = try SessionThread + .filter(id: threadId) + .select(.variant) + .asRequest(of: SessionThread.Variant.self) + .fetchOne(db) + else { throw MessageSenderError.noThread } // Create the interaction let interaction: Interaction = try Interaction( @@ -209,7 +224,11 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView .fetchOne(db), linkPreviewUrl: (isSharingUrl ? attachments.first?.linkPreviewDraft?.urlString : nil) ).inserted(db) - + + guard let interactionId: Int64 = interaction.id else { + throw StorageError.failedToSave + } + // If the user is sharing a Url, there is a LinkPreview and it doesn't match an existing // one then add it now if @@ -220,33 +239,49 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView try LinkPreview( url: linkPreviewDraft.urlString, title: linkPreviewDraft.title, - attachmentId: LinkPreview.saveAttachmentIfPossible( - db, - imageData: linkPreviewDraft.jpegImageData, - mimeType: OWSMimeTypeImageJpeg - ) + attachmentId: LinkPreview + .generateAttachmentIfPossible( + imageData: linkPreviewDraft.jpegImageData, + mimeType: OWSMimeTypeImageJpeg + )? + .inserted(db) + .id ).insert(db) } - - return try MessageSender.sendNonDurably( + + // Prepare any attachments + try Attachment.process( db, - interaction: interaction, - with: finalAttachments, - in: thread + data: Attachment.prepare(attachments: finalAttachments), + for: interactionId ) + + // Prepare the message send data + return try MessageSender + .preparedSendData( + db, + interaction: interaction, + threadId: threadId, + threadVariant: threadVariant + ) } - .done { [weak self] _ in - // Suspend the database - NotificationCenter.default.post(name: Database.suspendNotification, object: self) - activityIndicator.dismiss { } - self?.shareVC?.shareViewWasCompleted() - } - .catch { [weak self] error in - // Suspend the database - NotificationCenter.default.post(name: Database.suspendNotification, object: self) - activityIndicator.dismiss { } - self?.shareVC?.shareViewFailed(error: error) - } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .flatMap { MessageSender.performUploadsIfNeeded(preparedSendData: $0) } + .flatMap { MessageSender.sendImmediate(preparedSendData: $0) } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sinkUntilComplete( + receiveCompletion: { [weak self] result in + // Suspend the database + NotificationCenter.default.post(name: Database.suspendNotification, object: self) + activityIndicator.dismiss { } + + switch result { + case .finished: self?.shareNavController?.shareViewWasCompleted() + case .failure(let error): self?.shareNavController?.shareViewFailed(error: error) + } + } + ) } } diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 93035647f..2d07a43cd 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -29,6 +29,7 @@ public class ThreadPickerViewModel { .fetchAll(db) } .removeDuplicates() + .handleEvents(didFail: { SNLog("[ThreadPickerViewModel] Observation failed with error: \($0)") }) // MARK: - Functions diff --git a/SessionSnodeKit/Configuration.swift b/SessionSnodeKit/Configuration.swift index 5035bfa78..301a8e3af 100644 --- a/SessionSnodeKit/Configuration.swift +++ b/SessionSnodeKit/Configuration.swift @@ -1,10 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import SessionUtilitiesKit -public enum SNSnodeKit { // Just to make the external API nice - public static func migrations() -> TargetMigrations { +public enum SNSnodeKit: MigratableTarget { // Just to make the external API nice + public static func migrations(_ db: Database) -> TargetMigrations { return TargetMigrations( identifier: .snodeKit, migrations: [ @@ -17,7 +18,8 @@ public enum SNSnodeKit { // Just to make the external API nice ], [ _004_FlagMessageHashAsDeletedOrInvalid.self - ] + ], + [] // Add job priorities ] ) } diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index b44201841..de155ba1f 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -17,7 +17,7 @@ enum _003_YDBToGRDBMigration: Migration { static func migrate(_ db: Database) throws { guard let dbConnection: YapDatabaseConnection = SUKLegacy.newDatabaseConnection() else { - SNLog("[Migration Warning] No legacy database, skipping \(target.key(with: self))") + SNLogNotTests("[Migration Warning] No legacy database, skipping \(target.key(with: self))") return } diff --git a/SessionSnodeKit/Database/Models/Snode.swift b/SessionSnodeKit/Database/Models/Snode.swift index 9bcf6bc8c..595b37901 100644 --- a/SessionSnodeKit/Database/Models/Snode.swift +++ b/SessionSnodeKit/Database/Models/Snode.swift @@ -60,7 +60,7 @@ extension Snode { } catch { SNLog("Failed to parse snode: \(error.localizedDescription).") - throw HTTP.Error.invalidJSON + throw HTTPError.invalidJSON } } } @@ -89,8 +89,10 @@ internal extension Snode { return try SnodeSet .filter(SnodeSet.Columns.key.like("\(SnodeSet.onionRequestPathPrefix)%")) - .order(SnodeSet.Columns.nodeIndex) - .order(SnodeSet.Columns.key) + .order( + SnodeSet.Columns.nodeIndex, + SnodeSet.Columns.key + ) .including(required: SnodeSet.node) .asRequest(of: ResultWrapper.self) .fetchAll(db) diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 336a19de8..c0b7eff3c 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -52,18 +52,18 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist // MARK: - Convenience public extension SnodeReceivedMessageInfo { - private static func key(for snode: Snode, publicKey: String, namespace: Int) -> String { - guard namespace != SnodeAPI.defaultNamespace else { + private static func key(for snode: Snode, publicKey: String, namespace: SnodeAPI.Namespace) -> String { + guard namespace != .default else { return "\(snode.address):\(snode.port).\(publicKey)" } - return "\(snode.address):\(snode.port).\(publicKey).\(namespace)" + return "\(snode.address):\(snode.port).\(publicKey).\(namespace.rawValue)" } init( snode: Snode, publicKey: String, - namespace: Int, + namespace: SnodeAPI.Namespace, hash: String, expirationDateMs: Int64? ) { @@ -76,15 +76,15 @@ public extension SnodeReceivedMessageInfo { // 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) + static func pruneExpiredMessageHashInfo(for snode: Snode, namespace: SnodeAPI.Namespace, 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 + // 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 = SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey, namespace: namespace)) .isNotEmpty(db) @@ -111,10 +111,10 @@ public extension SnodeReceivedMessageInfo { /// This method fetches the last non-expired hash from the database for message retrieval /// - /// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's very common for - /// this method to be called after the hash value has been updated but before the various `read` threads have been updated, resulting in a - /// pointless fetch for data the app has already received - static func fetchLastNotExpired(for snode: Snode, namespace: Int, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { + /// **Note:** This method uses a `write` instead of a read because there is a single write queue for the database and it's + /// very common for this method to be called after the hash value has been updated but before the various `read` threads + /// have been updated, resulting in a pointless fetch for data the app has already received + static func fetchLastNotExpired(for snode: Snode, namespace: SnodeAPI.Namespace, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { return Storage.shared.read { db in let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo .filter( diff --git a/SessionSnodeKit/GetSnodePoolJob.swift b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift similarity index 52% rename from SessionSnodeKit/GetSnodePoolJob.swift rename to SessionSnodeKit/Jobs/GetSnodePoolJob.swift index 610101c80..c7b226189 100644 --- a/SessionSnodeKit/GetSnodePoolJob.swift +++ b/SessionSnodeKit/Jobs/GetSnodePoolJob.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import SignalCoreKit import SessionUtilitiesKit @@ -18,33 +19,44 @@ public enum GetSnodePoolJob: JobExecutor { deferred: @escaping (Job, Dependencies) -> (), dependencies: Dependencies = Dependencies() ) { - // 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, dependencies) - 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, dependencies) - return + SNLog("[GetSnodePoolJob] Has valid cached pool, running async instead") + SnodeAPI + .getSnodePool() + .subscribe(on: DispatchQueue.global(qos: .default)) + .sinkUntilComplete() + return success(job, false, dependencies) } + // If we don't have the snode pool cached then we should also try to build the path (this will + // speed up the onboarding process for new users because it can run before the user is created) SnodeAPI.getSnodePool() - .done(on: queue) { _ in success(job, false, dependencies) } - .catch(on: queue) { error in failure(job, error, false, dependencies) } - .retainUntilComplete() + .flatMap { _ in OnionRequestAPI.getPath(excluding: nil) } + .subscribe(on: queue) + .receive(on: queue) + .sinkUntilComplete( + receiveCompletion: { result in + switch result { + case .finished: + SNLog("[GetSnodePoolJob] Completed") + success(job, false, dependencies) + + case .failure(let error): + SNLog("[GetSnodePoolJob] Failed due to error: \(error)") + failure(job, error, false, dependencies) + } + } + ) } public static func run() { GetSnodePoolJob.run( Job(variant: .getSnodePool), - queue: DispatchQueue.global(qos: .background), + queue: .global(qos: .background), success: { _, _, _ in }, failure: { _, _, _, _ in }, deferred: { _, _ in } diff --git a/SessionSnodeKit/Models/DeleteAllBeforeRequest.swift b/SessionSnodeKit/Models/DeleteAllBeforeRequest.swift new file mode 100644 index 000000000..6f4a83ad9 --- /dev/null +++ b/SessionSnodeKit/Models/DeleteAllBeforeRequest.swift @@ -0,0 +1,81 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public class DeleteAllBeforeRequest: SnodeAuthenticatedRequestBody { + enum CodingKeys: String, CodingKey { + case beforeMs = "before" + case namespace + } + + let beforeMs: UInt64 + let namespace: SnodeAPI.Namespace? + + // MARK: - Init + + public init( + beforeMs: UInt64, + namespace: SnodeAPI.Namespace?, + pubkey: String, + timestampMs: UInt64, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.beforeMs = beforeMs + self.namespace = namespace + + super.init( + pubkey: pubkey, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey, + timestampMs: timestampMs + ) + } + + // MARK: - Coding + + override public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(beforeMs, forKey: .beforeMs) + + // If no namespace is specified it defaults to the default namespace only (namespace + // 0), so instead in this case we want to explicitly delete from `all` namespaces + switch namespace { + case .some(let namespace): try container.encode(namespace, forKey: .namespace) + case .none: try container.encode("all", forKey: .namespace) + } + + try super.encode(to: encoder) + } + + // MARK: - Abstract Methods + + override func generateSignature() throws -> [UInt8] { + /// Ed25519 signature of `("delete_before" || namespace || before)`, signed by + /// `pubkey`. Must be base64 encoded (json) or bytes (OMQ). `namespace` is the stringified + /// version of the given non-default namespace parameter (i.e. "-42" or "all"), or the empty + /// string for the default namespace (whether explicitly given or not). + let verificationBytes: [UInt8] = SnodeAPI.Endpoint.deleteAllBefore.rawValue.bytes + .appending( + contentsOf: (namespace == nil ? + "all" : + namespace?.verificationString + )?.bytes + ) + .appending(contentsOf: "\(beforeMs)".data(using: .ascii)?.bytes) + + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionSnodeKit/Models/DeleteAllBeforeResponse.swift b/SessionSnodeKit/Models/DeleteAllBeforeResponse.swift new file mode 100644 index 000000000..869a20b82 --- /dev/null +++ b/SessionSnodeKit/Models/DeleteAllBeforeResponse.swift @@ -0,0 +1,56 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +public class DeleteAllBeforeResponse: SnodeRecursiveResponse {} + +// MARK: - ValidatableResponse + +extension DeleteAllBeforeResponse: ValidatableResponse { + typealias ValidationData = UInt64 + typealias ValidationResponse = Bool + + /// Just one response in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { 1 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: UInt64 + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) + else { + result[next.key] = false + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't delete data from: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't delete data from: \(next.key).") + } + return + } + + /// Signature of `( PUBKEY_HEX || BEFORE || DELETEDHASH[0] || ... || DELETEDHASH[N] )` + /// signed by the node's ed25519 pubkey. When doing a multi-namespace delete the `DELETEDHASH` + /// values are totally ordered (i.e. among all the hashes deleted regardless of namespace) + let verificationBytes: [UInt8] = userX25519PublicKey.bytes + .appending(contentsOf: "\(validationData)".data(using: .ascii)?.bytes) + .appending(contentsOf: next.value.deleted.joined().bytes) + + result[next.key] = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + } + + return try Self.validated(map: validationMap) + } +} diff --git a/SessionSnodeKit/Models/DeleteAllMessagesRequest.swift b/SessionSnodeKit/Models/DeleteAllMessagesRequest.swift new file mode 100644 index 000000000..5104dbcca --- /dev/null +++ b/SessionSnodeKit/Models/DeleteAllMessagesRequest.swift @@ -0,0 +1,75 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public class DeleteAllMessagesRequest: SnodeAuthenticatedRequestBody { + enum CodingKeys: String, CodingKey { + case namespace + } + + /// The message namespace from which to delete messages. The request will delete all messages + /// from the specific namespace, or from all namespaces when not provided + /// + /// **Note:** If omitted when sending the request, messages are deleted from the default namespace + /// only (namespace 0) + let namespace: SnodeAPI.Namespace + + // MARK: - Init + + public init( + namespace: SnodeAPI.Namespace, + pubkey: String, + timestampMs: UInt64, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.namespace = namespace + + super.init( + pubkey: pubkey, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey, + timestampMs: timestampMs + ) + } + + // MARK: - Coding + + override public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + // The 'all' namespace should be sent through as `all` instead of a numerical value + switch namespace { + case .all: try container.encode(namespace.verificationString, forKey: .namespace) + default: try container.encode(namespace, forKey: .namespace) + } + + try super.encode(to: encoder) + } + + // MARK: - Abstract Methods + + override func generateSignature() throws -> [UInt8] { + /// Ed25519 signature of `( "delete_all" || namespace || timestamp )`, where + /// `namespace` is the empty string for the default namespace (whether explicitly specified or + /// not), and otherwise the stringified version of the namespace parameter (i.e. "99" or "-42" or "all"). + /// The signature must be signed by the ed25519 pubkey in `pubkey` (omitting the leading prefix). + /// Must be base64 encoded for json requests; binary for OMQ requests. + let verificationBytes: [UInt8] = SnodeAPI.Endpoint.deleteAll.rawValue.bytes + .appending(contentsOf: namespace.verificationString.bytes) + .appending(contentsOf: timestampMs.map { "\($0)" }?.data(using: .ascii)?.bytes) + + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionSnodeKit/Models/DeleteAllMessagesResponse.swift b/SessionSnodeKit/Models/DeleteAllMessagesResponse.swift new file mode 100644 index 000000000..2ecfdc8b6 --- /dev/null +++ b/SessionSnodeKit/Models/DeleteAllMessagesResponse.swift @@ -0,0 +1,91 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +public class DeleteAllMessagesResponse: SnodeRecursiveResponse {} + +// MARK: - SwarmItem + +public extension DeleteAllMessagesResponse { + class SwarmItem: SnodeSwarmItem { + private enum CodingKeys: String, CodingKey { + case deleted + } + + public let deleted: [String] + public let deletedNamespaced: [String: [String]] + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + if let decodedDeletedNamespaced: [String: [String]] = try? container.decode([String: [String]].self, forKey: .deleted) { + deletedNamespaced = decodedDeletedNamespaced + + /// **Note:** When doing a multi-namespace delete the `DELETEDHASH` values are totally + /// ordered (i.e. among all the hashes deleted regardless of namespace) + deleted = decodedDeletedNamespaced + .reduce(into: []) { result, next in result.append(contentsOf: next.value) } + .sorted() + } + else { + deleted = ((try? container.decode([String].self, forKey: .deleted)) ?? []) + deletedNamespaced = [:] + } + + try super.init(from: decoder) + } + } +} + +// MARK: - ValidatableResponse + +extension DeleteAllMessagesResponse: ValidatableResponse { + typealias ValidationData = UInt64 + typealias ValidationResponse = Bool + + /// Just one response in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { 1 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: UInt64 + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) + else { + result[next.key] = false + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't delete data from: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't delete data from: \(next.key).") + } + return + } + + /// Signature of `( PUBKEY_HEX || TIMESTAMP || DELETEDHASH[0] || ... || DELETEDHASH[N] )` + /// signed by the node's ed25519 pubkey. When doing a multi-namespace delete the `DELETEDHASH` + /// values are totally ordered (i.e. among all the hashes deleted regardless of namespace) + let verificationBytes: [UInt8] = userX25519PublicKey.bytes + .appending(contentsOf: "\(validationData)".data(using: .ascii)?.bytes) + .appending(contentsOf: next.value.deleted.joined().bytes) + + result[next.key] = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + } + + return try Self.validated(map: validationMap) + } +} diff --git a/SessionSnodeKit/Models/DeleteMessagesRequest.swift b/SessionSnodeKit/Models/DeleteMessagesRequest.swift new file mode 100644 index 000000000..1210d78a3 --- /dev/null +++ b/SessionSnodeKit/Models/DeleteMessagesRequest.swift @@ -0,0 +1,70 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public class DeleteMessagesRequest: SnodeAuthenticatedRequestBody { + enum CodingKeys: String, CodingKey { + case messageHashes = "messages" + case requireSuccessfulDeletion = "required" + } + + let messageHashes: [String] + let requireSuccessfulDeletion: Bool + + // MARK: - Init + + public init( + messageHashes: [String], + requireSuccessfulDeletion: Bool, + pubkey: String, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.messageHashes = messageHashes + self.requireSuccessfulDeletion = requireSuccessfulDeletion + + super.init( + pubkey: pubkey, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey + ) + } + + // MARK: - Coding + + override public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(messageHashes, forKey: .messageHashes) + + // Omitting the value is the same as false so omit to save data + if requireSuccessfulDeletion { + try container.encode(requireSuccessfulDeletion, forKey: .requireSuccessfulDeletion) + } + + try super.encode(to: encoder) + } + + // MARK: - Abstract Methods + + override func generateSignature() throws -> [UInt8] { + /// Ed25519 signature of `("delete" || messages...)`; this signs the value constructed + /// by concatenating "delete" and all `messages` values, using `pubkey` to sign. Must be base64 + /// encoded for json requests; binary for OMQ requests. + let verificationBytes: [UInt8] = SnodeAPI.Endpoint.deleteMessages.rawValue.bytes + .appending(contentsOf: messageHashes.joined().bytes) + + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionSnodeKit/Models/DeleteMessagesResponse.swift b/SessionSnodeKit/Models/DeleteMessagesResponse.swift new file mode 100644 index 000000000..180d68498 --- /dev/null +++ b/SessionSnodeKit/Models/DeleteMessagesResponse.swift @@ -0,0 +1,76 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +public class DeleteMessagesResponse: SnodeRecursiveResponse {} + +// MARK: - SwarmItem + +public extension DeleteMessagesResponse { + class SwarmItem: SnodeSwarmItem { + private enum CodingKeys: String, CodingKey { + case deleted + } + + public let deleted: [String] + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + deleted = ((try? container.decode([String].self, forKey: .deleted)) ?? []) + + try super.init(from: decoder) + } + } +} + +// MARK: - ValidatableResponse + +extension DeleteMessagesResponse: ValidatableResponse { + typealias ValidationData = [String] + typealias ValidationResponse = Bool + + /// Just one response in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { 1 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: [String] + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) + else { + result[next.key] = false + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't delete data from: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't delete data from: \(next.key).") + } + return + } + + /// The signature format is `( PUBKEY_HEX || RMSG[0] || ... || RMSG[N] || DMSG[0] || ... || DMSG[M] )` + let verificationBytes: [UInt8] = userX25519PublicKey.bytes + .appending(contentsOf: validationData.joined().bytes) + .appending(contentsOf: next.value.deleted.joined().bytes) + + result[next.key] = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + } + + return try Self.validated(map: validationMap) + } +} diff --git a/SessionSnodeKit/Models/GetMessagesRequest.swift b/SessionSnodeKit/Models/GetMessagesRequest.swift new file mode 100644 index 000000000..e338874f8 --- /dev/null +++ b/SessionSnodeKit/Models/GetMessagesRequest.swift @@ -0,0 +1,82 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public class GetMessagesRequest: SnodeAuthenticatedRequestBody { + enum CodingKeys: String, CodingKey { + case lastHash = "last_hash" + case namespace + case maxCount = "max_count" + case maxSize = "max_size" + } + + let lastHash: String + let namespace: SnodeAPI.Namespace? + let maxCount: Int64? + let maxSize: Int64? + + // MARK: - Init + + public init( + lastHash: String, + namespace: SnodeAPI.Namespace?, + pubkey: String, + subkey: String?, + timestampMs: UInt64, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8], + maxCount: Int64? = nil, + maxSize: Int64? = nil + ) { + self.lastHash = lastHash + self.namespace = namespace + self.maxCount = maxCount + self.maxSize = maxSize + + super.init( + pubkey: pubkey, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey, + subkey: subkey, + timestampMs: timestampMs + ) + } + + // MARK: - Coding + + override public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(lastHash, forKey: .lastHash) + try container.encodeIfPresent(namespace, forKey: .namespace) + try container.encodeIfPresent(maxCount, forKey: .maxCount) + try container.encodeIfPresent(maxSize, forKey: .maxSize) + + try super.encode(to: encoder) + } + + // MARK: - Abstract Methods + + override func generateSignature() throws -> [UInt8] { + /// Ed25519 signature of `("retrieve" || namespace || timestamp)` (if using a non-0 + /// namespace), or `("retrieve" || timestamp)` when fetching from the default namespace. Both + /// namespace and timestamp are the base10 expressions of the relevant values. Must be base64 + /// encoded for json requests; binary for OMQ requests. + let verificationBytes: [UInt8] = SnodeAPI.Endpoint.getMessages.rawValue.bytes + .appending(contentsOf: namespace?.verificationString.bytes) + .appending(contentsOf: timestampMs.map { "\($0)" }?.data(using: .ascii)?.bytes) + + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionSnodeKit/Models/GetMessagesResponse.swift b/SessionSnodeKit/Models/GetMessagesResponse.swift new file mode 100644 index 000000000..b0aa02809 --- /dev/null +++ b/SessionSnodeKit/Models/GetMessagesResponse.swift @@ -0,0 +1,38 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public class GetMessagesResponse: SnodeResponse { + private enum CodingKeys: String, CodingKey { + case messages + case more + } + + public class RawMessage: Codable { + private enum CodingKeys: String, CodingKey { + case data + case expiration + case hash + case timestamp + } + + public let data: String + public let expiration: Int64? + public let hash: String + public let timestamp: Int64 + } + + public let messages: [RawMessage] + public let more: Bool + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + messages = try container.decode([RawMessage].self, forKey: .messages) + more = try container.decode(Bool.self, forKey: .more) + + try super.init(from: decoder) + } +} diff --git a/SessionSnodeKit/Models/GetNetworkTimestampResponse.swift b/SessionSnodeKit/Models/GetNetworkTimestampResponse.swift new file mode 100644 index 000000000..71428bab9 --- /dev/null +++ b/SessionSnodeKit/Models/GetNetworkTimestampResponse.swift @@ -0,0 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public struct GetNetworkTimestampResponse: Decodable { + enum CodingKeys: String, CodingKey { + case timestamp + case version + } + + let timestamp: UInt64 + let version: [UInt64] + } +} diff --git a/SessionSnodeKit/Models/GetServiceNodesRequest.swift b/SessionSnodeKit/Models/GetServiceNodesRequest.swift new file mode 100644 index 000000000..6fae67c3e --- /dev/null +++ b/SessionSnodeKit/Models/GetServiceNodesRequest.swift @@ -0,0 +1,31 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public struct GetServiceNodesRequest: Encodable { + enum CodingKeys: String, CodingKey { + case activeOnly = "active_only" + case limit + case fields + } + + let activeOnly: Bool + let limit: Int? + let fields: Fields + + public struct Fields: Encodable { + enum CodingKeys: String, CodingKey { + case publicIp = "public_ip" + case storagePort = "storage_port" + case pubkeyEd25519 = "pubkey_ed25519" + case pubkeyX25519 = "pubkey_x25519" + } + + let publicIp: Bool + let storagePort: Bool + let pubkeyEd25519: Bool + let pubkeyX25519: Bool + } + } +} diff --git a/SessionSnodeKit/Models/GetStatsResponse.swift b/SessionSnodeKit/Models/GetStatsResponse.swift new file mode 100644 index 000000000..5e33cd03b --- /dev/null +++ b/SessionSnodeKit/Models/GetStatsResponse.swift @@ -0,0 +1,16 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +extension SnodeAPI { + public struct GetStatsResponse: Codable { + private enum CodingKeys: String, CodingKey { + case versionString = "version" + } + + let versionString: String? + + var version: Version? { versionString.map { Version.from($0) } } + } +} diff --git a/SessionSnodeKit/Models/GetSwarmRequest.swift b/SessionSnodeKit/Models/GetSwarmRequest.swift new file mode 100644 index 000000000..86d1534b9 --- /dev/null +++ b/SessionSnodeKit/Models/GetSwarmRequest.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public struct GetSwarmRequest: Encodable { + enum CodingKeys: String, CodingKey { + case pubkey + } + + let pubkey: String + } +} diff --git a/SessionSnodeKit/Models/LegacyGetMessagesRequest.swift b/SessionSnodeKit/Models/LegacyGetMessagesRequest.swift new file mode 100644 index 000000000..70dc7aa3a --- /dev/null +++ b/SessionSnodeKit/Models/LegacyGetMessagesRequest.swift @@ -0,0 +1,34 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + /// This is the legacy unauthenticated message retrieval request + public struct LegacyGetMessagesRequest: Encodable { + enum CodingKeys: String, CodingKey { + case pubkey + case lastHash = "last_hash" + case namespace + case maxCount = "max_count" + case maxSize = "max_size" + } + + let pubkey: String + let lastHash: String + let namespace: SnodeAPI.Namespace? + let maxCount: Int64? + let maxSize: Int64? + + // MARK: - Coding + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(pubkey, forKey: .pubkey) + try container.encode(lastHash, forKey: .lastHash) + try container.encodeIfPresent(namespace, forKey: .namespace) + try container.encodeIfPresent(maxCount, forKey: .maxCount) + try container.encodeIfPresent(maxSize, forKey: .maxSize) + } + } +} diff --git a/SessionSnodeKit/Models/LegacySendMessageRequest.swift b/SessionSnodeKit/Models/LegacySendMessageRequest.swift new file mode 100644 index 000000000..08cfe72ef --- /dev/null +++ b/SessionSnodeKit/Models/LegacySendMessageRequest.swift @@ -0,0 +1,24 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + /// This is the legacy unauthenticated message store request + public struct LegacySendMessagesRequest: Encodable { + enum CodingKeys: String, CodingKey { + case namespace + } + + let message: SnodeMessage + let namespace: SnodeAPI.Namespace + + // MARK: - Coding + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try message.encode(to: encoder) + try container.encode(namespace, forKey: .namespace) + } + } +} diff --git a/SessionSnodeKit/Models/ONSResolveRequest.swift b/SessionSnodeKit/Models/ONSResolveRequest.swift new file mode 100644 index 000000000..eaef29085 --- /dev/null +++ b/SessionSnodeKit/Models/ONSResolveRequest.swift @@ -0,0 +1,15 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public struct ONSResolveRequest: Encodable { + enum CodingKeys: String, CodingKey { + case type + case base64EncodedNameHash = "name_hash" + } + + let type: Int64 + let base64EncodedNameHash: String + } +} diff --git a/SessionSnodeKit/Models/ONSResolveResponse.swift b/SessionSnodeKit/Models/ONSResolveResponse.swift new file mode 100644 index 000000000..5208bbb4c --- /dev/null +++ b/SessionSnodeKit/Models/ONSResolveResponse.swift @@ -0,0 +1,84 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +extension SnodeAPI { + public class ONSResolveResponse: SnodeResponse { + private struct Result: Codable { + enum CodingKeys: String, CodingKey { + case nonce + case encryptedValue = "encrypted_value" + } + + fileprivate let nonce: String? + fileprivate let encryptedValue: String + } + + enum CodingKeys: String, CodingKey { + case result + } + + private let result: Result + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + result = try container.decode(Result.self, forKey: .result) + + try super.init(from: decoder) + } + + // MARK: - Convenience + + func sessionId(sodium: Sodium, nameBytes: [UInt8], nameHashBytes: [UInt8]) throws -> String { + let ciphertext: [UInt8] = Data(hex: result.encryptedValue).bytes + + // Handle old Argon2-based encryption used before HF16 + guard let hexEncodedNonce: String = result.nonce else { + let salt: [UInt8] = Data(repeating: 0, count: sodium.pwHash.SaltBytes).bytes + + guard + let key: [UInt8] = sodium.pwHash.hash( + outputLength: sodium.secretBox.KeyBytes, + passwd: nameBytes, + salt: salt, + opsLimit: sodium.pwHash.OpsLimitModerate, + memLimit: sodium.pwHash.MemLimitModerate, + alg: .Argon2ID13 + ) + else { throw SnodeAPIError.hashingFailed } + + let nonce: [UInt8] = Data(repeating: 0, count: sodium.secretBox.NonceBytes).bytes + + guard let sessionIdAsData: [UInt8] = sodium.secretBox.open(authenticatedCipherText: ciphertext, secretKey: key, nonce: nonce) else { + throw SnodeAPIError.decryptionFailed + } + + return sessionIdAsData.toHexString() + } + + let nonceBytes: [UInt8] = Data(hex: hexEncodedNonce).bytes + + // xchacha-based encryption + // key = H(name, key=H(name)) + guard let key: [UInt8] = sodium.genericHash.hash(message: nameBytes, key: nameHashBytes) else { + throw SnodeAPIError.hashingFailed + } + guard + // Should always be equal in practice + ciphertext.count >= (SessionId.byteCount + sodium.aead.xchacha20poly1305ietf.ABytes), + let sessionIdAsData = sodium.aead.xchacha20poly1305ietf.decrypt( + authenticatedCipherText: ciphertext, + secretKey: key, + nonce: nonceBytes + ) + else { throw SnodeAPIError.decryptionFailed } + + return sessionIdAsData.toHexString() + } + } +} diff --git a/SessionSnodeKit/Models/OxenDaemonRPCRequest.swift b/SessionSnodeKit/Models/OxenDaemonRPCRequest.swift new file mode 100644 index 000000000..935961963 --- /dev/null +++ b/SessionSnodeKit/Models/OxenDaemonRPCRequest.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct OxenDaemonRPCRequest: Encodable { + private enum CodingKeys: String, CodingKey { + case endpoint + case body = "params" + } + + private let endpoint: String + private let body: T + + public init( + endpoint: SnodeAPI.Endpoint, + body: T + ) { + self.endpoint = endpoint.rawValue + self.body = body + } +} diff --git a/SessionSnodeKit/Models/RequestInfo.swift b/SessionSnodeKit/Models/RequestInfo.swift deleted file mode 100644 index 8072364df..000000000 --- a/SessionSnodeKit/Models/RequestInfo.swift +++ /dev/null @@ -1,11 +0,0 @@ -// 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/RevokeSubkeyRequest.swift b/SessionSnodeKit/Models/RevokeSubkeyRequest.swift new file mode 100644 index 000000000..203532123 --- /dev/null +++ b/SessionSnodeKit/Models/RevokeSubkeyRequest.swift @@ -0,0 +1,60 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public class RevokeSubkeyRequest: SnodeAuthenticatedRequestBody { + enum CodingKeys: String, CodingKey { + case subkeyToRevoke = "revoke_subkey" + } + + let subkeyToRevoke: String + + // MARK: - Init + + public init( + subkeyToRevoke: String, + pubkey: String, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.subkeyToRevoke = subkeyToRevoke + + super.init( + pubkey: pubkey, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey + ) + } + + // MARK: - Coding + + override public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(subkeyToRevoke, forKey: .subkeyToRevoke) + + try super.encode(to: encoder) + } + + // MARK: - Abstract Methods + + override func generateSignature() throws -> [UInt8] { + /// Ed25519 signature of `("revoke_subkey" || subkey)`; this signs the subkey tag, + /// using `pubkey` to sign. Must be base64 encoded for json requests; binary for OMQ requests. + let verificationBytes: [UInt8] = SnodeAPI.Endpoint.revokeSubkey.rawValue.bytes + .appending(contentsOf: subkeyToRevoke.bytes) + + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionSnodeKit/Models/RevokeSubkeyResponse.swift b/SessionSnodeKit/Models/RevokeSubkeyResponse.swift new file mode 100644 index 000000000..867e4eb0e --- /dev/null +++ b/SessionSnodeKit/Models/RevokeSubkeyResponse.swift @@ -0,0 +1,56 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +public class RevokeSubkeyResponse: SnodeRecursiveResponse {} + +// MARK: - ValidatableResponse + +extension RevokeSubkeyResponse: ValidatableResponse { + typealias ValidationData = String + typealias ValidationResponse = Bool + + /// All responses in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { -1 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: String + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = try swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) + else { + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't revoke subkey from: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't revoke subkey from: \(next.key).") + } + return + } + + /// Signature of `( PUBKEY_HEX || SUBKEY_TAG_BYTES )` where `SUBKEY_TAG_BYTES` is the + /// requested subkey tag for revocation + let verificationBytes: [UInt8] = userX25519PublicKey.bytes + .appending(contentsOf: validationData.bytes) + let isValid: Bool = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + + // If the update signature is invalid then we want to fail here + guard isValid else { throw SnodeAPIError.signatureVerificationFailed } + + result[next.key] = isValid + } + + return try Self.validated(map: validationMap) + } +} diff --git a/SessionSnodeKit/Models/SendMessageRequest.swift b/SessionSnodeKit/Models/SendMessageRequest.swift new file mode 100644 index 000000000..5058382df --- /dev/null +++ b/SessionSnodeKit/Models/SendMessageRequest.swift @@ -0,0 +1,76 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public class SendMessageRequest: SnodeAuthenticatedRequestBody { + enum CodingKeys: String, CodingKey { + case namespace + } + + let message: SnodeMessage + let namespace: SnodeAPI.Namespace + + // MARK: - Init + + public init( + message: SnodeMessage, + namespace: SnodeAPI.Namespace, + subkey: String?, + timestampMs: UInt64, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.message = message + self.namespace = namespace + + super.init( + pubkey: message.recipient, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey, + subkey: subkey, + timestampMs: timestampMs + ) + } + + // MARK: - Coding + + override public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + /// **Note:** We **MUST** do the `message.encode` before we call `super.encode` because otherwise + /// it will override the `timestampMs` value with the value in the message which is incorrect - we actually want the + /// `timestampMs` value at the time the request was made so that older messages stuck in the job queue don't + /// end up failing due to being outside the approved timestamp window (clients use the timestamp within the message + /// data rather than this one anyway) + try message.encode(to: encoder) + try container.encode(namespace, forKey: .namespace) + + try super.encode(to: encoder) + } + + // MARK: - Abstract Methods + + override func generateSignature() throws -> [UInt8] { + /// Ed25519 signature of `("store" || namespace || timestamp)`, where namespace and + /// `timestamp` are the base10 expression of the namespace and `timestamp` values. Must be + /// base64 encoded for json requests; binary for OMQ requests. For non-05 type pubkeys (i.e. non + /// session ids) the signature will be verified using `pubkey`. For 05 pubkeys, see the following + /// option. + let verificationBytes: [UInt8] = SnodeAPI.Endpoint.sendMessage.rawValue.bytes + .appending(contentsOf: namespace.verificationString.bytes) + .appending(contentsOf: timestampMs.map { "\($0)" }?.data(using: .ascii)?.bytes) + + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionSnodeKit/Models/SendMessageResponse.swift b/SessionSnodeKit/Models/SendMessageResponse.swift new file mode 100644 index 000000000..2e21c8d23 --- /dev/null +++ b/SessionSnodeKit/Models/SendMessageResponse.swift @@ -0,0 +1,99 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +public class SendMessagesResponse: SnodeRecursiveResponse { + private enum CodingKeys: String, CodingKey { + case hash + case swarm + } + + public let hash: String + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + hash = try container.decode(String.self, forKey: .hash) + + try super.init(from: decoder) + } +} + +// MARK: - SwarmItem + +public extension SendMessagesResponse { + class SwarmItem: SnodeSwarmItem { + private enum CodingKeys: String, CodingKey { + case hash + case already + } + + public let hash: String? + + /// `true` if a message with this hash was already stored + /// + /// **Note:** The `hash` is still included and signed even if this occurs + public let already: Bool + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + hash = try? container.decode(String.self, forKey: .hash) + already = ((try? container.decode(Bool.self, forKey: .already)) ?? false) + + try super.init(from: decoder) + } + } +} + +// MARK: - ValidatableResponse + +extension SendMessagesResponse: ValidatableResponse { + typealias ValidationData = Void + typealias ValidationResponse = Bool + + /// Half of the responses in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { -2 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: Void + ) throws -> [String: Bool] { + let validationMap: [String: Bool] = swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64), + let hash: String = next.value.hash + else { + result[next.key] = false + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't store message on: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't store message on: \(next.key).") + } + return + } + + /// Signature of `hash` signed by the node's ed25519 pubkey + let verificationBytes: [UInt8] = hash.bytes + + result[next.key] = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + } + + return try Self.validated(map: validationMap) + } +} diff --git a/SessionSnodeKit/Models/SnodeAPIEndpoint.swift b/SessionSnodeKit/Models/SnodeAPIEndpoint.swift deleted file mode 100644 index 63ffd5334..000000000 --- a/SessionSnodeKit/Models/SnodeAPIEndpoint.swift +++ /dev/null @@ -1,16 +0,0 @@ -// 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/SnodeAuthenticatedRequestBody.swift b/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift new file mode 100644 index 000000000..7e349a719 --- /dev/null +++ b/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift @@ -0,0 +1,56 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public class SnodeAuthenticatedRequestBody: Encodable { + private enum CodingKeys: String, CodingKey { + case pubkey + case subkey + case timestampMs = "timestamp" + case ed25519PublicKey = "pubkey_ed25519" + case signatureBase64 = "signature" + } + + private let pubkey: String + private let ed25519PublicKey: [UInt8] + internal let ed25519SecretKey: [UInt8] + private let subkey: String? + internal let timestampMs: UInt64? + + // MARK: - Initialization + + public init( + pubkey: String, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8], + subkey: String? = nil, + timestampMs: UInt64? = nil + ) { + self.pubkey = pubkey + self.ed25519PublicKey = ed25519PublicKey + self.ed25519SecretKey = ed25519SecretKey + self.subkey = subkey + self.timestampMs = timestampMs + } + + // MARK: - Codable + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + // Generate the signature for the request for encoding + let signatureBase64: String = try generateSignature().toBase64() + try container.encode(pubkey, forKey: .pubkey) + try container.encodeIfPresent(subkey, forKey: .subkey) + try container.encodeIfPresent(timestampMs, forKey: .timestampMs) + try container.encode(ed25519PublicKey.toHexString(), forKey: .ed25519PublicKey) + try container.encode(signatureBase64, forKey: .signatureBase64) + } + + // MARK: - Abstract Functions + + func generateSignature() throws -> [UInt8] { + preconditionFailure("abstract class - override in subclass") + } +} diff --git a/SessionSnodeKit/Models/SnodeBatchRequest.swift b/SessionSnodeKit/Models/SnodeBatchRequest.swift new file mode 100644 index 000000000..7824ca44b --- /dev/null +++ b/SessionSnodeKit/Models/SnodeBatchRequest.swift @@ -0,0 +1,63 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +internal extension SnodeAPI { + struct BatchRequest: Encodable { + let requests: [Child] + + init(requests: [Info]) { + self.requests = requests.map { $0.child } + } + + // MARK: - BatchRequest.Info + + struct Info { + public let responseType: Decodable.Type + fileprivate let child: Child + + public init(request: SnodeRequest, responseType: R.Type) { + self.child = Child(request: request) + self.responseType = HTTP.BatchSubResponse.self + } + + public init(request: SnodeRequest) { + self.init( + request: request, + responseType: NoResponse.self + ) + } + } + + // MARK: - BatchRequest.Child + + struct Child: Encodable { + enum CodingKeys: String, CodingKey { + case method + case params + } + + let endpoint: SnodeAPI.Endpoint + + /// 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 -> ())? + + init(request: SnodeRequest) { + self.endpoint = request.endpoint + + self.jsonBodyEncoder = { [body = request.body] container, key in + try container.encode(body, forKey: key) + } + } + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(endpoint.rawValue, forKey: .method) + try jsonBodyEncoder?(&container, .params) + } + } + } +} diff --git a/SessionSnodeKit/SnodeMessage.swift b/SessionSnodeKit/Models/SnodeMessage.swift similarity index 86% rename from SessionSnodeKit/SnodeMessage.swift rename to SessionSnodeKit/Models/SnodeMessage.swift index 0b4c9cb7c..07cf76f09 100644 --- a/SessionSnodeKit/SnodeMessage.swift +++ b/SessionSnodeKit/Models/SnodeMessage.swift @@ -1,15 +1,14 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import PromiseKit +import Foundation import SessionUtilitiesKit public final class SnodeMessage: Codable { private enum CodingKeys: String, CodingKey { - case recipient = "pubKey" + case recipient = "pubkey" case data case ttl case timestampMs = "timestamp" - case nonce } /// The hex encoded public key of the recipient. @@ -53,13 +52,9 @@ extension SnodeMessage { 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(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/Models/SnodeReceivedMessage.swift b/SessionSnodeKit/Models/SnodeReceivedMessage.swift index bf2832f5c..6e20921d1 100644 --- a/SessionSnodeKit/Models/SnodeReceivedMessage.swift +++ b/SessionSnodeKit/Models/SnodeReceivedMessage.swift @@ -9,27 +9,28 @@ public struct SnodeReceivedMessage: CustomDebugStringConvertible { public static let defaultExpirationSeconds: Int64 = ((15 * 24 * 60 * 60) * 1000) public let info: SnodeReceivedMessageInfo + public let namespace: SnodeAPI.Namespace 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 { + init?( + snode: Snode, + publicKey: String, + namespace: SnodeAPI.Namespace, + rawMessage: GetMessagesResponse.RawMessage + ) { + guard let data: Data = Data(base64Encoded: rawMessage.data) 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) + hash: rawMessage.hash, + expirationDateMs: (rawMessage.expiration ?? SnodeReceivedMessage.defaultExpirationSeconds) ) + self.namespace = namespace self.data = data } diff --git a/SessionSnodeKit/Models/SnodeRecursiveResponse.swift b/SessionSnodeKit/Models/SnodeRecursiveResponse.swift new file mode 100644 index 000000000..2d4dbb1e4 --- /dev/null +++ b/SessionSnodeKit/Models/SnodeRecursiveResponse.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public class SnodeRecursiveResponse: SnodeResponse { + private enum CodingKeys: String, CodingKey { + case swarm + } + + internal let swarm: [String: T] + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + swarm = try container.decode([String: T].self, forKey: .swarm) + + try super.init(from: decoder) + } +} diff --git a/SessionSnodeKit/Models/SnodeRequest.swift b/SessionSnodeKit/Models/SnodeRequest.swift new file mode 100644 index 000000000..f8d777569 --- /dev/null +++ b/SessionSnodeKit/Models/SnodeRequest.swift @@ -0,0 +1,33 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +public struct SnodeRequest: Encodable { + private enum CodingKeys: String, CodingKey { + case method + case body = "params" + } + + internal let endpoint: SnodeAPI.Endpoint + internal let body: T + + // MARK: - Initialization + + public init( + endpoint: SnodeAPI.Endpoint, + body: T + ) { + self.endpoint = endpoint + self.body = body + } + + // MARK: - Codable + + public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(endpoint.rawValue, forKey: .method) + try container.encode(body, forKey: .body) + } +} diff --git a/SessionSnodeKit/Models/SnodeResponse.swift b/SessionSnodeKit/Models/SnodeResponse.swift new file mode 100644 index 000000000..cba1f63b0 --- /dev/null +++ b/SessionSnodeKit/Models/SnodeResponse.swift @@ -0,0 +1,13 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public class SnodeResponse: Codable { + private enum CodingKeys: String, CodingKey { + case hardFork = "hf" + case timeOffset = "t" + } + + internal let hardFork: [Int] + internal let timeOffset: Int64 +} diff --git a/SessionSnodeKit/Models/SnodeSwarmItem.swift b/SessionSnodeKit/Models/SnodeSwarmItem.swift new file mode 100644 index 000000000..72fc5b003 --- /dev/null +++ b/SessionSnodeKit/Models/SnodeSwarmItem.swift @@ -0,0 +1,52 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public class SnodeSwarmItem: Codable { + private enum CodingKeys: String, CodingKey { + case signatureBase64 = "signature" + + case failed + case timeout + case code + case reason + case badPeerResponse = "bad_peer_response" + case queryFailure = "query_failure" + } + + /// Should be present as long as the request didn't fail + public let signatureBase64: String? + + /// `true` if the request failed, possibly accompanied by one of the following: `timeout`, `code`, + /// `reason`, `badPeerResponse`, `queryFailure` + public let failed: Bool + + /// `true` if the inter-swarm request timed out + public let timeout: Bool? + + /// `X` if the inter-swarm request returned error code `X` + public let code: Int? + + /// a reason string, e.g. propagating a thrown exception messages + public let reason: String? + + /// `true` if the peer returned an unparseable response + public let badPeerResponse: Bool? + + /// `true` if the database failed to perform the query + public let queryFailure: Bool? + + // MARK: - Initialization + + public required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + signatureBase64 = try? container.decode(String.self, forKey: .signatureBase64) + failed = ((try? container.decode(Bool.self, forKey: .failed)) ?? false) + timeout = try? container.decode(Bool.self, forKey: .timeout) + code = try? container.decode(Int.self, forKey: .code) + reason = try? container.decode(String.self, forKey: .reason) + badPeerResponse = try? container.decode(Bool.self, forKey: .badPeerResponse) + queryFailure = try? container.decode(Bool.self, forKey: .queryFailure) + } +} diff --git a/SessionSnodeKit/Models/SwarmSnode.swift b/SessionSnodeKit/Models/SwarmSnode.swift index 727d79191..9ba62558d 100644 --- a/SessionSnodeKit/Models/SwarmSnode.swift +++ b/SessionSnodeKit/Models/SwarmSnode.swift @@ -40,7 +40,7 @@ extension SwarmSnode { } catch { SNLog("Failed to parse snode: \(error.localizedDescription).") - throw HTTP.Error.invalidJSON + throw HTTPError.invalidJSON } } diff --git a/SessionSnodeKit/Models/UpdateExpiryAllRequest.swift b/SessionSnodeKit/Models/UpdateExpiryAllRequest.swift new file mode 100644 index 000000000..92e5b0de6 --- /dev/null +++ b/SessionSnodeKit/Models/UpdateExpiryAllRequest.swift @@ -0,0 +1,85 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public class UpdateExpiryAllRequest: SnodeAuthenticatedRequestBody { + enum CodingKeys: String, CodingKey { + case expiryMs = "expiry" + case namespace + } + + let expiryMs: UInt64 + + /// The message namespace from which to change message expiries. The request will update the expiry for + /// all messages from the specific namespace, or from all namespaces when not provided + /// + /// **Note:** If omitted when sending the request, message expiries are updated from the default namespace + /// only (namespace 0) + let namespace: SnodeAPI.Namespace? + + // MARK: - Init + + public init( + expiryMs: UInt64, + namespace: SnodeAPI.Namespace?, + pubkey: String, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8] + ) { + self.expiryMs = expiryMs + self.namespace = namespace + + super.init( + pubkey: pubkey, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey + ) + } + + // MARK: - Coding + + override public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(expiryMs, forKey: .expiryMs) + + // If no namespace is specified it defaults to the default namespace only (namespace + // 0), so instead in this case we want to explicitly delete from `all` namespaces + switch namespace { + case .some(let namespace): try container.encode(namespace, forKey: .namespace) + case .none: try container.encode("all", forKey: .namespace) + } + + try super.encode(to: encoder) + } + + // MARK: - Abstract Methods + + override func generateSignature() throws -> [UInt8] { + /// Ed25519 signature of `("expire_all" || namespace || expiry)`, signed by `pubkey`. Must be + /// base64 encoded (json) or bytes (OMQ). namespace should be the stringified namespace for + /// non-default namespace expiries (i.e. "42", "-99", "all"), or an empty string for the default + /// namespace (whether or not explicitly provided). + let verificationBytes: [UInt8] = SnodeAPI.Endpoint.expireAll.rawValue.bytes + .appending( + contentsOf: (namespace == nil ? + "all" : + namespace?.verificationString + )?.bytes + ) + .appending(contentsOf: "\(expiryMs)".data(using: .ascii)?.bytes) + + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionSnodeKit/Models/UpdateExpiryAllResponse.swift b/SessionSnodeKit/Models/UpdateExpiryAllResponse.swift new file mode 100644 index 000000000..389199565 --- /dev/null +++ b/SessionSnodeKit/Models/UpdateExpiryAllResponse.swift @@ -0,0 +1,96 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +public class UpdateExpiryAllResponse: SnodeRecursiveResponse {} + +// MARK: - SwarmItem + +public extension UpdateExpiryAllResponse { + class SwarmItem: SnodeSwarmItem { + private enum CodingKeys: String, CodingKey { + case updated + } + + public let updated: [String] + public let updatedNamespaced: [String: [String]] + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + if let decodedUpdatedNamespaced: [String: [String]] = try? container.decode([String: [String]].self, forKey: .updated) { + updatedNamespaced = decodedUpdatedNamespaced + + /// **Note:** When doing a multi-namespace delete the `UPDATED` values are totally + /// ordered (i.e. among all the hashes deleted regardless of namespace) + updated = decodedUpdatedNamespaced + .reduce(into: []) { result, next in result.append(contentsOf: next.value) } + .sorted() + } + else { + updated = ((try? container.decode([String].self, forKey: .updated)) ?? []) + updatedNamespaced = [:] + } + + try super.init(from: decoder) + } + } +} + +// MARK: - ValidatableResponse + +extension UpdateExpiryAllResponse: ValidatableResponse { + typealias ValidationData = UInt64 + typealias ValidationResponse = [String] + + /// All responses in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { -1 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: UInt64 + ) throws -> [String: [String]] { + let validationMap: [String: [String]] = try swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) + else { + result[next.key] = [] + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't update expiry from: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't update expiry from: \(next.key).") + } + return + } + + /// Signature of `( PUBKEY_HEX || EXPIRY || UPDATED[0] || ... || UPDATED[N] )` + /// signed by the node's ed25519 pubkey. When doing a multi-namespace delete the `UPDATED` + /// values are totally ordered (i.e. among all the hashes deleted regardless of namespace) + let verificationBytes: [UInt8] = userX25519PublicKey.bytes + .appending(contentsOf: "\(validationData)".data(using: .ascii)?.bytes) + .appending(contentsOf: next.value.updated.joined().bytes) + + let isValid: Bool = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + + // If the update signature is invalid then we want to fail here + guard isValid else { throw SnodeAPIError.signatureVerificationFailed } + + result[next.key] = next.value.updated + } + + return try Self.validated(map: validationMap, totalResponseCount: swarm.count) + } +} diff --git a/SessionSnodeKit/Models/UpdateExpiryRequest.swift b/SessionSnodeKit/Models/UpdateExpiryRequest.swift new file mode 100644 index 000000000..dd8579947 --- /dev/null +++ b/SessionSnodeKit/Models/UpdateExpiryRequest.swift @@ -0,0 +1,97 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension SnodeAPI { + public class UpdateExpiryRequest: SnodeAuthenticatedRequestBody { + enum CodingKeys: String, CodingKey { + case messageHashes = "messages" + case expiryMs = "expiry" + case shorten + case extend + } + + /// Array of message hash strings (as provided by the storage server) to update. Messages can be from any namespace(s) + let messageHashes: [String] + + /// The new expiry timestamp (milliseconds since unix epoch). Must be >= 60s ago. The new expiry can be anywhere from + /// current time up to the maximum TTL (30 days) from now; specifying a later timestamp will be truncated to the maximum + let expiryMs: UInt64 + + /// If provided and set to true then the expiry is only shortened, but not extended. If the expiry is already at or before the given + /// `expiry` timestamp then expiry will not be changed + /// + /// **Note:** This option is only supported starting at network version 19.3). This option is not permitted when using + /// subkey authentication + let shorten: Bool? + + /// If provided and set to true then the expiry is only extended, but not shortened. If the expiry is already at or beyond + /// the given `expiry` timestamp then expiry will not be changed + /// + /// **Note:** This option is only supported starting at network version 19.3. This option is mutually exclusive of "shorten" + let extend: Bool? + + // MARK: - Init + + public init( + messageHashes: [String], + expiryMs: UInt64, + shorten: Bool? = nil, + extend: Bool? = nil, + pubkey: String, + ed25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8], + subkey: String? + ) { + self.messageHashes = messageHashes + self.expiryMs = expiryMs + self.shorten = shorten + self.extend = extend + + super.init( + pubkey: pubkey, + ed25519PublicKey: ed25519PublicKey, + ed25519SecretKey: ed25519SecretKey, + subkey: subkey + ) + } + + // MARK: - Coding + + override public func encode(to encoder: Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(messageHashes, forKey: .messageHashes) + try container.encode(expiryMs, forKey: .expiryMs) + try container.encodeIfPresent(shorten, forKey: .shorten) + try container.encodeIfPresent(extend, forKey: .extend) + + try super.encode(to: encoder) + } + + // MARK: - Abstract Methods + + override func generateSignature() throws -> [UInt8] { + /// Ed25519 signature of `("expire" || ShortenOrExtend || expiry || messages[0] || ...` + /// ` || messages[N])` where `expiry` is the expiry timestamp expressed as a string. + /// `ShortenOrExtend` is string signature must be base64 "shorten" if the shorten option is given (and true), + /// "extend" if `extend` is true, and empty otherwise. The signature must be base64 encoded (json) or bytes (bt). + let verificationBytes: [UInt8] = SnodeAPI.Endpoint.expire.rawValue.bytes + .appending(contentsOf: (shorten == true ? "shorten".bytes : [])) + .appending(contentsOf: (extend == true ? "extend".bytes : [])) + .appending(contentsOf: "\(expiryMs)".data(using: .ascii)?.bytes) + .appending(contentsOf: messageHashes.joined().bytes) + + guard + let signatureBytes: [UInt8] = sodium.wrappedValue.sign.signature( + message: verificationBytes, + secretKey: ed25519SecretKey + ) + else { + throw SnodeAPIError.signingFailed + } + + return signatureBytes + } + } +} diff --git a/SessionSnodeKit/Models/UpdateExpiryResponse.swift b/SessionSnodeKit/Models/UpdateExpiryResponse.swift new file mode 100644 index 000000000..dd812e7fa --- /dev/null +++ b/SessionSnodeKit/Models/UpdateExpiryResponse.swift @@ -0,0 +1,104 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium +import SessionUtilitiesKit + +public class UpdateExpiryResponse: SnodeRecursiveResponse {} + +// MARK: - SwarmItem + +public extension UpdateExpiryResponse { + class SwarmItem: SnodeSwarmItem { + private enum CodingKeys: String, CodingKey { + case updated + case unchanged + case expiry + } + + public let updated: [String] + public let unchanged: [String: UInt64] + public let expiry: UInt64? + + // MARK: - Initialization + + required init(from decoder: Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + + updated = ((try? container.decode([String].self, forKey: .updated)) ?? []) + unchanged = ((try? container.decode([String: UInt64].self, forKey: .unchanged)) ?? [:]) + expiry = try? container.decode(UInt64.self, forKey: .expiry) + + try super.init(from: decoder) + } + } +} + +// MARK: - ValidatableResponse + +extension UpdateExpiryResponse: ValidatableResponse { + typealias ValidationData = [String] + typealias ValidationResponse = [(hash: String, expiry: UInt64)] + + /// All responses in the swarm must be valid + internal static var requiredSuccessfulResponses: Int { -1 } + + internal func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: [String] + ) throws -> [String: [(hash: String, expiry: UInt64)]] { + let validationMap: [String: [(hash: String, expiry: UInt64)]] = try swarm.reduce(into: [:]) { result, next in + guard + !next.value.failed, + let appliedExpiry: UInt64 = next.value.expiry, + let signatureBase64: String = next.value.signatureBase64, + let encodedSignature: Data = Data(base64Encoded: signatureBase64) + else { + result[next.key] = [] + + if let reason: String = next.value.reason, let statusCode: Int = next.value.code { + SNLog("Couldn't update expiry from: \(next.key) due to error: \(reason) (\(statusCode)).") + } + else { + SNLog("Couldn't update expiry from: \(next.key).") + } + return + } + + /// Signature of + /// `( PUBKEY_HEX || EXPIRY || RMSGs... || UMSGs... || CMSG_EXPs... )` + /// where RMSGs are the requested expiry hashes, UMSGs are the actual updated hashes, and + /// CMSG_EXPs are (HASH || EXPIRY) values, ascii-sorted by hash, for the unchanged message + /// hashes included in the "unchanged" field. The signature uses the node's ed25519 pubkey. + /// + /// **Note:** If `updated` is empty then the `expiry` value will match the value that was + /// included in the original request + let verificationBytes: [UInt8] = userX25519PublicKey.bytes + .appending(contentsOf: "\(appliedExpiry)".data(using: .ascii)?.bytes) + .appending(contentsOf: validationData.joined().bytes) + .appending(contentsOf: next.value.updated.sorted().joined().bytes) + .appending(contentsOf: next.value.unchanged + .sorted(by: { lhs, rhs in lhs.key < rhs.key }) + .reduce(into: [UInt8]()) { result, nextUnchanged in + result.append(contentsOf: nextUnchanged.key.bytes) + result.append(contentsOf: "\(nextUnchanged.value)".data(using: .ascii)?.bytes ?? []) + } + ) + let isValid: Bool = sodium.sign.verify( + message: verificationBytes, + publicKey: Data(hex: next.key).bytes, + signature: encodedSignature.bytes + ) + + // If the update signature is invalid then we want to fail here + guard isValid else { throw SnodeAPIError.signatureVerificationFailed } + + result[next.key] = next.value.updated + .map { ($0, appliedExpiry) } + .appending(contentsOf: next.value.unchanged.map { ($0.key, $0.value) }) + } + + return try Self.validated(map: validationMap, totalResponseCount: swarm.count) + } +} diff --git a/SessionSnodeKit/Networking/NetworkType+SnodeKit.swift b/SessionSnodeKit/Networking/NetworkType+SnodeKit.swift new file mode 100644 index 000000000..04969735d --- /dev/null +++ b/SessionSnodeKit/Networking/NetworkType+SnodeKit.swift @@ -0,0 +1,3 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SessionSnodeKit/Notification+OnionRequestAPI.swift b/SessionSnodeKit/Networking/Notification+OnionRequestAPI.swift similarity index 100% rename from SessionSnodeKit/Notification+OnionRequestAPI.swift rename to SessionSnodeKit/Networking/Notification+OnionRequestAPI.swift diff --git a/SessionSnodeKit/Networking/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/Networking/OnionRequestAPI+Encryption.swift new file mode 100644 index 000000000..36aa1c995 --- /dev/null +++ b/SessionSnodeKit/Networking/OnionRequestAPI+Encryption.swift @@ -0,0 +1,87 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import CryptoKit +import SessionUtilitiesKit + +internal extension OnionRequestAPI { + static func encode(ciphertext: Data, json: JSON) -> AnyPublisher { + // The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 | + guard + JSONSerialization.isValidJSONObject(json), + let jsonAsData = try? JSONSerialization.data(withJSONObject: json, options: [ .fragmentsAllowed ]) + else { + return Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() + } + + let ciphertextSize = Int32(ciphertext.count).littleEndian + let ciphertextSizeAsData = withUnsafePointer(to: ciphertextSize) { Data(bytes: $0, count: MemoryLayout.size) } + + return Just(ciphertextSizeAsData + ciphertext + jsonAsData) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + /// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. + static func encrypt( + _ payload: Data, + for destination: OnionRequestAPIDestination + ) -> AnyPublisher { + switch destination { + case .snode(let snode): + // Need to wrap the payload for snode requests + return encode(ciphertext: payload, json: [ "headers" : "" ]) + .tryMap { data -> AES.GCM.EncryptionResult in + try AES.GCM.encrypt(data, for: snode.x25519PublicKey) + } + .eraseToAnyPublisher() + + case .server(_, _, let serverX25519PublicKey, _, _): + do { + return Just(try AES.GCM.encrypt(payload, for: serverX25519PublicKey)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + catch { + return Fail(error: error) + .eraseToAnyPublisher() + } + } + } + + /// Encrypts the previous encryption result (i.e. that of the hop after this one) for this hop. Use this to build the layers of an onion request. + static func encryptHop( + from lhs: OnionRequestAPIDestination, + to rhs: OnionRequestAPIDestination, + using previousEncryptionResult: AES.GCM.EncryptionResult + ) -> AnyPublisher { + var parameters: JSON + + switch rhs { + case .snode(let snode): + let snodeED25519PublicKey = snode.ed25519PublicKey + parameters = [ "destination" : snodeED25519PublicKey ] + + case .server(let host, let target, _, let scheme, let port): + let scheme = scheme ?? "https" + let port = port ?? (scheme == "https" ? 443 : 80) + parameters = [ "host" : host, "target" : target, "method" : "POST", "protocol" : scheme, "port" : port ] + } + + parameters["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString() + + let x25519PublicKey: String = { + switch lhs { + case .snode(let snode): return snode.x25519PublicKey + case .server(_, _, let serverX25519PublicKey, _, _): + return serverX25519PublicKey + } + }() + + return encode(ciphertext: previousEncryptionResult.ciphertext, json: parameters) + .tryMap { data -> AES.GCM.EncryptionResult in try AES.GCM.encrypt(data, for: x25519PublicKey) } + .eraseToAnyPublisher() + } +} diff --git a/SessionSnodeKit/Networking/OnionRequestAPI.swift b/SessionSnodeKit/Networking/OnionRequestAPI.swift new file mode 100644 index 000000000..926c49008 --- /dev/null +++ b/SessionSnodeKit/Networking/OnionRequestAPI.swift @@ -0,0 +1,847 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import CryptoKit +import GRDB +import SessionUtilitiesKit + +public protocol OnionRequestAPIType { + static func sendOnionRequest(_ payload: Data, to snode: Snode, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> + static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval) -> AnyPublisher<(ResponseInfoType, Data?), Error> +} + +public extension OnionRequestAPIType { + static func sendOnionRequest(_ payload: Data, to snode: Snode) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return sendOnionRequest(payload, to: snode, timeout: HTTP.defaultTimeout) + } + + static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + return sendOnionRequest(request, to: server, with: x25519PublicKey, timeout: HTTP.defaultTimeout) + } +} + +/// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. +public enum OnionRequestAPI: OnionRequestAPIType { + private static var buildPathsPublisher: Atomic?> = Atomic(nil) + private static var pathFailureCount: Atomic<[[Snode]: UInt]> = Atomic([:]) + private static var snodeFailureCount: Atomic<[Snode: UInt]> = Atomic([:]) + public static var guardSnodes: Atomic> = Atomic([]) + + // Not a set to ensure we consistently show the same path to the user + private static var _paths: Atomic<[[Snode]]?> = Atomic(nil) + public static var paths: [[Snode]] { + get { + if let paths: [[Snode]] = _paths.wrappedValue { return paths } + + let results: [[Snode]]? = Storage.shared.read { db in + try? Snode.fetchAllOnionRequestPaths(db) + } + + if results?.isEmpty == false { _paths.mutate { $0 = results } } + return (results ?? []) + } + set { _paths.mutate { $0 = newValue } } + } + + // MARK: - Settings + + public static let maxRequestSize = 10_000_000 // 10 MB + /// The number of snodes (including the guard snode) in a path. + private static let pathSize: UInt = 3 + /// The number of times a path can fail before it's replaced. + private static let pathFailureThreshold: UInt = 3 + /// The number of times a snode can fail before it's replaced. + private static let snodeFailureThreshold: UInt = 3 + /// The number of paths to maintain. + public static let targetPathCount: UInt = 2 + + /// The number of guard snodes required to maintain `targetPathCount` paths. + private static var targetGuardSnodeCount: UInt { return targetPathCount } // One per path + + // MARK: - Onion Building Result + + private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AES.GCM.EncryptionResult, destinationSymmetricKey: Data) + + // MARK: - Private API + + /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. + private static func testSnode(_ snode: Snode) -> AnyPublisher { + let url = "\(snode.address):\(snode.port)/get_stats/v1" + let timeout: TimeInterval = 3 // Use a shorter timeout for testing + + return HTTP.execute(.get, url, timeout: timeout) + .decoded(as: SnodeAPI.GetStatsResponse.self) + .tryMap { response -> Void in + guard let version: Version = response.version else { throw OnionRequestAPIError.missingSnodeVersion } + guard version >= Version(major: 2, minor: 0, patch: 7) else { + SNLog("Unsupported snode version: \(version.stringValue).") + throw OnionRequestAPIError.unsupportedSnodeVersion(version.stringValue) + } + + return () + } + .eraseToAnyPublisher() + } + + /// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with + /// `Error.insufficientSnodes` if not enough (reliable) snodes are available. + private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> AnyPublisher, Error> { + guard guardSnodes.wrappedValue.count < targetGuardSnodeCount else { + return Just(guardSnodes.wrappedValue) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + SNLog("Populating guard snode cache.") + // Sync on LokiAPI.workQueue + var unusedSnodes = SnodeAPI.snodePool.wrappedValue.subtracting(reusableGuardSnodes) + let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) + + guard unusedSnodes.count >= (targetGuardSnodeCount - reusableGuardSnodeCount) else { + return Fail(error: OnionRequestAPIError.insufficientSnodes) + .eraseToAnyPublisher() + } + + func getGuardSnode() -> AnyPublisher { + // randomElement() uses the system's default random generator, which + // is cryptographically secure + guard let candidate = unusedSnodes.randomElement() else { + return Fail(error: OnionRequestAPIError.insufficientSnodes) + .eraseToAnyPublisher() + } + + 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) + .map { _ in candidate } + .catch { _ in + return Just(()) + .setFailureType(to: Error.self) + .delay(for: .milliseconds(100), scheduler: Threading.workQueue) + .flatMap { _ in getGuardSnode() } + } + .eraseToAnyPublisher() + } + + let publishers = (0..<(targetGuardSnodeCount - reusableGuardSnodeCount)) + .map { _ in getGuardSnode() } + + return Publishers.MergeMany(publishers) + .collect() + .map { output in Set(output) } + .handleEvents( + receiveOutput: { output in + OnionRequestAPI.guardSnodes.mutate { $0 = output } + } + ) + .eraseToAnyPublisher() + } + + /// 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: [[Snode]]) -> AnyPublisher<[[Snode]], Error> { + if let existingBuildPathsPublisher = buildPathsPublisher.wrappedValue { + return existingBuildPathsPublisher + } + + return buildPathsPublisher.mutate { result in + /// It was possible for multiple threads to call this at the same time resulting in duplicate promises getting created, while + /// this should no longer be possible (as the `wrappedValue` should now properly be blocked) this is a sanity check + /// to make sure we don't create an additional promise when one already exists + if let previouslyBlockedPublisher: AnyPublisher<[[Snode]], Error> = result { + return previouslyBlockedPublisher + } + + SNLog("Building onion request paths.") + DispatchQueue.main.async { + NotificationCenter.default.post(name: .buildingPaths, object: nil) + } + + /// Need to include the post-request code and a `shareReplay` within the publisher otherwise it can still be executed + /// multiple times as a result of multiple subscribers + let reusableGuardSnodes = reusablePaths.map { $0[0] } + let publisher: AnyPublisher<[[Snode]], Error> = getGuardSnodes(reusing: reusableGuardSnodes) + .flatMap { (guardSnodes: Set) -> AnyPublisher<[[Snode]], Error> in + var unusedSnodes: Set = SnodeAPI.snodePool.wrappedValue + .subtracting(guardSnodes) + .subtracting(reusablePaths.flatMap { $0 }) + let reusableGuardSnodeCount: UInt = UInt(reusableGuardSnodes.count) + let pathSnodeCount: UInt = (targetGuardSnodeCount - reusableGuardSnodeCount) * pathSize - (targetGuardSnodeCount - reusableGuardSnodeCount) + + guard unusedSnodes.count >= pathSnodeCount else { + return Fail<[[Snode]], Error>(error: OnionRequestAPIError.insufficientSnodes) + .eraseToAnyPublisher() + } + + // Don't test path snodes as this would reveal the user's IP to them + let paths: [[Snode]] = guardSnodes + .subtracting(reusableGuardSnodes) + .map { (guardSnode: Snode) in + let result: [Snode] = [guardSnode] + .appending( + contentsOf: (0..<(pathSize - 1)) + .map { _ in + // randomElement() uses the system's default random generator, + // which is cryptographically secure + let pathSnode: Snode = 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 + } + + return Just(paths) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + .handleEvents( + receiveOutput: { output in + OnionRequestAPI.paths = (output + reusablePaths) + + Storage.shared.write { db in + SNLog("Persisting onion request paths to database.") + try? output.save(db) + } + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .pathsBuilt, object: nil) + } + }, + receiveCompletion: { _ in buildPathsPublisher.mutate { $0 = nil } } + ) + .shareReplay(1) + .eraseToAnyPublisher() + + /// Actually assign the atomic value + result = publisher + + return publisher + } + } + + /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. + internal static func getPath(excluding snode: Snode?) -> AnyPublisher<[Snode], Error> { + guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } + + let paths: [[Snode]] = OnionRequestAPI.paths + var cancellable: [AnyCancellable] = [] + + if !paths.isEmpty { + guardSnodes.mutate { + $0.formUnion([ paths[0][0] ]) + + if paths.count >= 2 { + $0.formUnion([ paths[1][0] ]) + } + } + } + + // randomElement() uses the system's default random generator, which is cryptographically secure + if + paths.count >= targetPathCount, + let targetPath: [Snode] = paths + .filter({ snode == nil || !$0.contains(snode!) }) + .randomElement() + { + return Just(targetPath) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + 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 + .subscribe(on: DispatchQueue.global(qos: .background)) + .sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in }) + .store(in: &cancellable) + + return Just(path) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + else { + return buildPaths(reusing: paths) + .flatMap { paths in + guard let path: [Snode] = paths.filter({ !$0.contains(snode) }).randomElement() else { + return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes) + .eraseToAnyPublisher() + } + + return Just(path) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + } + else { + buildPaths(reusing: paths) // Re-build paths in the background + .subscribe(on: DispatchQueue.global(qos: .background)) + .sink(receiveCompletion: { _ in cancellable = [] }, receiveValue: { _ in }) + .store(in: &cancellable) + + guard let path: [Snode] = paths.randomElement() else { + return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes) + .eraseToAnyPublisher() + } + + return Just(path) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + } + else { + return buildPaths(reusing: []) + .flatMap { paths in + if let snode = snode { + if let path = paths.filter({ !$0.contains(snode) }).randomElement() { + return Just(path) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes) + .eraseToAnyPublisher() + } + + guard let path: [Snode] = paths.randomElement() else { + return Fail<[Snode], Error>(error: OnionRequestAPIError.insufficientSnodes) + .eraseToAnyPublisher() + } + + return Just(path) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + } + + private static func dropGuardSnode(_ snode: Snode) { + guardSnodes.mutate { snodes in snodes = snodes.filter { $0 != snode } } + } + + private static func drop(_ snode: Snode) throws { + // We repair the path here because we can do it sync. In the case where we drop a whole + // path we leave the re-building up to getPath(excluding:) because re-building the path + // in that case is async. + OnionRequestAPI.snodeFailureCount.mutate { $0[snode] = 0 } + var oldPaths = paths + guard let pathIndex = oldPaths.firstIndex(where: { $0.contains(snode) }) else { return } + var path = oldPaths[pathIndex] + guard let snodeIndex = path.firstIndex(of: snode) else { return } + path.remove(at: snodeIndex) + let unusedSnodes = SnodeAPI.snodePool.wrappedValue.subtracting(oldPaths.flatMap { $0 }) + 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 + + Storage.shared.write { db in + SNLog("Persisting onion request paths to database.") + try? newPaths.save(db) + } + } + + private static func drop(_ path: [Snode]) { + OnionRequestAPI.pathFailureCount.mutate { $0[path] = 0 } + var paths = OnionRequestAPI.paths + guard let pathIndex = paths.firstIndex(of: path) else { return } + paths.remove(at: pathIndex) + OnionRequestAPI.paths = paths + + Storage.shared.write { db in + guard !paths.isEmpty else { + SNLog("Clearing onion request paths.") + 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: Data, + targetedAt destination: OnionRequestAPIDestination + ) -> AnyPublisher { + var guardSnode: Snode! + var targetSnodeSymmetricKey: Data! // Needed by invoke(_:on:with:) to decrypt the response sent back by the destination + var encryptionResult: AES.GCM.EncryptionResult! + var snodeToExclude: Snode? + + if case .snode(let snode) = destination { snodeToExclude = snode } + + return getPath(excluding: snodeToExclude) + .flatMap { path -> AnyPublisher in + guardSnode = path.first! + + // Encrypt in reverse order, i.e. the destination first + return encrypt(payload, for: destination) + .flatMap { r -> AnyPublisher 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() -> AnyPublisher { + guard !path.isEmpty else { + return Just(encryptionResult) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + let lhs = OnionRequestAPIDestination.snode(path.removeLast()) + return OnionRequestAPI + .encryptHop(from: lhs, to: rhs, using: encryptionResult) + .flatMap { r -> AnyPublisher in + encryptionResult = r + rhs = lhs + return addLayer() + } + .eraseToAnyPublisher() + } + + return addLayer() + } + .eraseToAnyPublisher() + } + .map { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) } + .eraseToAnyPublisher() + } + + // MARK: - Public API + + /// Sends an onion request to `snode`. Builds new paths as needed. + public static func sendOnionRequest( + _ payload: Data, + to snode: Snode, + timeout: TimeInterval = HTTP.defaultTimeout + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + /// **Note:** Currently the service nodes only support V3 Onion Requests + return sendOnionRequest( + with: payload, + to: OnionRequestAPIDestination.snode(snode), + version: .v3, + timeout: timeout + ) + } + + /// Sends an onion request to `server`. Builds new paths as needed. + public static func sendOnionRequest( + _ request: URLRequest, + to server: String, + with x25519PublicKey: String, + timeout: TimeInterval = HTTP.defaultTimeout + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + guard let url = request.url, let host = request.url?.host else { + return Fail(error: OnionRequestAPIError.invalidURL) + .eraseToAnyPublisher() + } + + let scheme: String? = url.scheme + let port: UInt16? = url.port.map { UInt16($0) } + + guard let payload: Data = generateV4Payload(for: request) else { + return Fail(error: OnionRequestAPIError.invalidRequestInfo) + .eraseToAnyPublisher() + } + + return OnionRequestAPI + .sendOnionRequest( + with: payload, + to: OnionRequestAPIDestination.server( + host: host, + target: OnionRequestAPIVersion.v4.rawValue, + x25519PublicKey: x25519PublicKey, + scheme: scheme, + port: port + ), + version: .v4, + timeout: timeout + ) + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + SNLog("Couldn't reach server: \(url) due to error: \(error).") + } + } + ) + .eraseToAnyPublisher() + } + + public static func sendOnionRequest( + with payload: Data, + to destination: OnionRequestAPIDestination, + version: OnionRequestAPIVersion, + timeout: TimeInterval = HTTP.defaultTimeout + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + var guardSnode: Snode? + + return buildOnion(around: payload, targetedAt: destination) + .flatMap { intermediate -> AnyPublisher<(ResponseInfoType, Data?), Error> in + guardSnode = intermediate.guardSnode + let url = "\(guardSnode!.address):\(guardSnode!.port)/onion_req/v2" + let finalEncryptionResult = intermediate.finalEncryptionResult + let onion = finalEncryptionResult.ciphertext + if case OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) { + SNLog("Approaching request size limit: ~\(onion.count) bytes.") + } + let parameters: JSON = [ + "ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString() + ] + let destinationSymmetricKey = intermediate.destinationSymmetricKey + + // TODO: Replace 'json' with a codable typed + return encode(ciphertext: onion, json: parameters) + .flatMap { body in HTTP.execute(.post, url, body: body, timeout: timeout) } + .flatMap { responseData in + handleResponse( + responseData: responseData, + destinationSymmetricKey: destinationSymmetricKey, + version: version, + destination: destination + ) + } + .eraseToAnyPublisher() + } + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: break + case .failure(let error): + guard + case HTTPError.httpRequestFailed(let statusCode, let data) = error, + let guardSnode: Snode = guardSnode + else { return } + + let path = paths.first { $0.contains(guardSnode) } + + func handleUnspecificError() { + guard let path = path else { return } + + var pathFailureCount: UInt = (OnionRequestAPI.pathFailureCount.wrappedValue[path] ?? 0) + pathFailureCount += 1 + + if pathFailureCount >= pathFailureThreshold { + dropGuardSnode(guardSnode) + path.forEach { snode in + SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode) // Intentionally don't throw + } + + drop(path) + } + else { + OnionRequestAPI.pathFailureCount.mutate { $0[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, data: data, forSnode: snode) // Intentionally don't throw + do { + try drop(snode) + } + catch { + handleUnspecificError() + } + } + else { + OnionRequestAPI.snodeFailureCount + .mutate { $0[snode] = snodeFailureCount } + } + } else { + // Do nothing + } + } + else if let message = json?["result"] as? String, message == "Loki Server error" { + // Do nothing + } + else if case .server(let host, _, _, _, _) = destination, host == "116.203.70.33" && statusCode == 0 { + // FIXME: Temporary thing to kick out nodes that can't talk to the V2 OGS yet + handleUnspecificError() + } + else if statusCode == 0 { // Timeout + // Do nothing + } + else { + handleUnspecificError() + } + } + } + ) + .eraseToAnyPublisher() + } + + // MARK: - Version Handling + + private static func generateV4Payload(for request: URLRequest) -> Data? { + guard let url = request.url else { return nil } + + // 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: HTTP.RequestInfo = HTTP.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 + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + 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 Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() + } + + guard let base64EncodedIVAndCiphertext = json["result"] as? String, let ivAndCiphertext = Data(base64Encoded: base64EncodedIVAndCiphertext), ivAndCiphertext.count >= AES.GCM.ivSize else { + return Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() + } + + do { + let data = try AES.GCM.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 Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() + } + + if statusCode == 406 { // Clock out of sync + SNLog("The user's clock is out of sync with the service node network.") + return Fail(error: SnodeAPIError.clockOutOfSync) + .eraseToAnyPublisher() + } + + if statusCode == 401 { // Signature verification failed + SNLog("Failed to verify the signature.") + return Fail(error: SnodeAPIError.signatureVerificationFailed) + .eraseToAnyPublisher() + } + + if let bodyAsString = json["body"] as? String { + guard let bodyAsData = bodyAsString.data(using: .utf8) else { + return Fail(error: HTTPError.invalidResponse) + .eraseToAnyPublisher() + } + guard let body = try? JSONSerialization.jsonObject(with: bodyAsData, options: [ .fragmentsAllowed ]) as? JSON else { + return Fail( + error: OnionRequestAPIError.httpRequestFailedAtDestination( + statusCode: UInt(statusCode), + data: bodyAsData, + destination: destination + ) + ).eraseToAnyPublisher() + } + + if let timestamp = body["t"] as? Int64 { + let offset = timestamp - Int64(floor(Date().timeIntervalSince1970 * 1000)) + SnodeAPI.clockOffsetMs.mutate { $0 = offset } + } + + guard 200...299 ~= statusCode else { + return Fail( + error: OnionRequestAPIError.httpRequestFailedAtDestination( + statusCode: UInt(statusCode), + data: bodyAsData, + destination: destination + ) + ).eraseToAnyPublisher() + } + + return Just((HTTP.ResponseInfo(code: statusCode, headers: [:]), bodyAsData)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + guard 200...299 ~= statusCode else { + return Fail( + error: OnionRequestAPIError.httpRequestFailedAtDestination( + statusCode: UInt(statusCode), + data: data, + destination: destination + ) + ).eraseToAnyPublisher() + } + + return Just((HTTP.ResponseInfo(code: statusCode, headers: [:]), data)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + catch { + return Fail(error: error) + .eraseToAnyPublisher() + } + + // V4 Onion Requests have a very different structure for responses + case .v4: + guard responseData.count >= AES.GCM.ivSize else { + return Fail(error: HTTPError.invalidResponse) + .eraseToAnyPublisher() + } + + do { + let data: Data = try AES.GCM.decrypt(responseData, with: destinationSymmetricKey) + + // Process the bencoded response + guard let processedResponse: (info: ResponseInfoType, body: Data?) = process(bencodedData: data) else { + return Fail(error: HTTPError.invalidResponse) + .eraseToAnyPublisher() + } + + // Custom handle a clock out of sync error (v4 returns '425' but included the '406' + // just in case) + guard processedResponse.info.code != 406 && processedResponse.info.code != 425 else { + SNLog("The user's clock is out of sync with the service node network.") + return Fail(error: SnodeAPIError.clockOutOfSync) + .eraseToAnyPublisher() + } + + guard processedResponse.info.code != 401 else { // Signature verification failed + SNLog("Failed to verify the signature.") + return Fail(error: SnodeAPIError.signatureVerificationFailed) + .eraseToAnyPublisher() + } + + // Handle error status codes + guard 200...299 ~= processedResponse.info.code else { + return Fail(error: OnionRequestAPIError.httpRequestFailedAtDestination( + statusCode: UInt(processedResponse.info.code), + data: data, + destination: destination + )).eraseToAnyPublisher() + } + + return Just(processedResponse) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + catch { + return Fail(error: error) + .eraseToAnyPublisher() + } + } + } + + public static func process(bencodedData data: Data) -> (info: ResponseInfoType, body: Data?)? { + // 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 nil + } + + 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 nil + } + + 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 (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 nil + } + + let dataBytes: Array = Array(data) + let dataEndIndex: Int = (dataBytes.count - suffixData.count) + let dataStartIndex: Int = (dataEndIndex - finalDataLength) + let finalDataBytes: ArraySlice = dataBytes[dataStartIndex.. = Atomic(Sodium()) + + private static var hasLoadedSnodePool: Atomic = Atomic(false) + private static var loadedSwarms: Atomic> = Atomic([]) + private static var getSnodePoolPublisher: Atomic, Error>?> = Atomic(nil) + + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + internal static var snodeFailureCount: Atomic<[Snode: UInt]> = Atomic([:]) + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + internal static var snodePool: Atomic> = Atomic([]) + + /// The offset between the user's clock and the Service Node's clock. Used in cases where the + /// user's clock is incorrect. + /// + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + public static var clockOffsetMs: Atomic = Atomic(0) + /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. + public static var swarmCache: Atomic<[String: Set]> = Atomic([:]) + + // MARK: - Hardfork version + + public static var hardfork = UserDefaults.standard[.hardfork] + public static var softfork = UserDefaults.standard[.softfork] + + // MARK: - Settings + + private static let maxRetryCount: Int = 8 + private static let minSwarmSnodeCount: Int = 3 + private static let seedNodePool: Set = { + guard !Features.useTestnet else { + return [ "http://public.loki.foundation:38157" ] + } + + return [ + "https://seed1.getsession.org:4432", + "https://seed2.getsession.org:4432", + "https://seed3.getsession.org:4432" + ] + }() + private static let snodeFailureThreshold: Int = 3 + private static let minSnodePoolCount: Int = 12 + + public static func currentOffsetTimestampMs() -> Int64 { + return Int64( + Int64(floor(Date().timeIntervalSince1970 * 1000)) + + SnodeAPI.clockOffsetMs.wrappedValue + ) + } + + // MARK: Snode Pool Interaction + + private static var hasInsufficientSnodes: Bool { snodePool.wrappedValue.count < minSnodePoolCount } + + private static func loadSnodePoolIfNeeded() { + guard !hasLoadedSnodePool.wrappedValue else { return } + + let fetchedSnodePool: Set = Storage.shared + .read { db in try Snode.fetchSet(db) } + .defaulting(to: []) + + snodePool.mutate { $0 = fetchedSnodePool } + hasLoadedSnodePool.mutate { $0 = true } + } + + private static func setSnodePool(_ db: Database? = nil, to newValue: Set) { + guard let db: Database = db else { + Storage.shared.write { db in setSnodePool(db, to: newValue) } + return + } + + snodePool.mutate { $0 = newValue } + + _ = try? Snode.deleteAll(db) + newValue.forEach { try? $0.save(db) } + } + + private static func dropSnodeFromSnodePool(_ snode: Snode) { + var snodePool = SnodeAPI.snodePool.wrappedValue + snodePool.remove(snode) + setSnodePool(to: snodePool) + } + + @objc public static func clearSnodePool() { + snodePool.mutate { $0.removeAll() } + + Threading.workQueue.async { + setSnodePool(to: []) + } + } + + // MARK: - Swarm Interaction + + private static func loadSwarmIfNeeded(for publicKey: String) { + guard !loadedSwarms.wrappedValue.contains(publicKey) else { return } + + let updatedCacheForKey: Set = Storage.shared + .read { db in try Snode.fetchSet(db, publicKey: publicKey) } + .defaulting(to: []) + + swarmCache.mutate { $0[publicKey] = updatedCacheForKey } + loadedSwarms.mutate { $0.insert(publicKey) } + } + + private static func setSwarm(to newValue: Set, for publicKey: String, persist: Bool = true) { + swarmCache.mutate { $0[publicKey] = newValue } + + guard persist else { return } + + Storage.shared.write { db in + try? newValue.save(db, key: publicKey) + } + } + + public static func dropSnodeFromSwarmIfNeeded(_ snode: Snode, publicKey: String) { + let swarmOrNil = swarmCache.wrappedValue[publicKey] + guard var swarm = swarmOrNil, let index = swarm.firstIndex(of: snode) else { return } + swarm.remove(at: index) + setSwarm(to: swarm, for: publicKey) + } + + // MARK: - Public API + + public static func hasCachedSnodesInclusingExpired() -> Bool { + loadSnodePoolIfNeeded() + + return !hasInsufficientSnodes + } + + public static func getSnodePool() -> AnyPublisher, Error> { + loadSnodePoolIfNeeded() + + let now: Date = Date() + let hasSnodePoolExpired: Bool = Storage.shared[.lastSnodePoolRefreshDate] + .map { now.timeIntervalSince($0) > 2 * 60 * 60 } + .defaulting(to: true) + let snodePool: Set = SnodeAPI.snodePool.wrappedValue + + guard hasInsufficientSnodes || hasSnodePoolExpired else { + return Just(snodePool) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + if let getSnodePoolPublisher: AnyPublisher, Error> = getSnodePoolPublisher.wrappedValue { + return getSnodePoolPublisher + } + + return getSnodePoolPublisher.mutate { result in + /// It was possible for multiple threads to call this at the same time resulting in duplicate promises getting created, while + /// this should no longer be possible (as the `wrappedValue` should now properly be blocked) this is a sanity check + /// to make sure we don't create an additional promise when one already exists + if let previouslyBlockedPublisher: AnyPublisher, Error> = result { + return previouslyBlockedPublisher + } + + let targetPublisher: AnyPublisher, Error> = { + guard snodePool.count >= minSnodePoolCount else { return getSnodePoolFromSeedNode() } + + return getSnodePoolFromSnode() + .catch { _ in getSnodePoolFromSeedNode() } + .eraseToAnyPublisher() + }() + + /// Need to include the post-request code and a `shareReplay` within the publisher otherwise it can still be executed + /// multiple times as a result of multiple subscribers + let publisher: AnyPublisher, Error> = targetPublisher + .tryFlatMap { snodePool -> AnyPublisher, Error> in + guard !snodePool.isEmpty else { throw SnodeAPIError.snodePoolUpdatingFailed } + + return Storage.shared + .writePublisher { db in + db[.lastSnodePoolRefreshDate] = now + setSnodePool(db, to: snodePool) + + return snodePool + } + .eraseToAnyPublisher() + } + .handleEvents( + receiveCompletion: { _ in getSnodePoolPublisher.mutate { $0 = nil } } + ) + .shareReplay(1) + .eraseToAnyPublisher() + + /// Actually assign the atomic value + result = publisher + + return publisher + + } + } + + public static func getSessionID(for onsName: String) -> AnyPublisher { + let validationCount = 3 + + // The name must be lowercased + let onsName = onsName.lowercased() + + // Hash the ONS name using BLAKE2b + let nameAsData = [UInt8](onsName.data(using: String.Encoding.utf8)!) + + guard let nameHash = sodium.wrappedValue.genericHash.hash(message: nameAsData) else { + return Fail(error: SnodeAPIError.hashingFailed) + .eraseToAnyPublisher() + } + + // Ask 3 different snodes for the Session ID associated with the given name hash + let base64EncodedNameHash = nameHash.toBase64() + + return Publishers + .MergeMany( + (0.. AnyPublisher in + SnodeAPI + .send( + request: SnodeRequest( + endpoint: .oxenDaemonRPCCall, + body: OxenDaemonRPCRequest( + endpoint: .daemonOnsResolve, + body: ONSResolveRequest( + type: 0, // type 0 means Session + base64EncodedNameHash: base64EncodedNameHash + ) + ) + ), + to: snode, + associatedWith: nil + ) + .decoded(as: ONSResolveResponse.self) + .tryMap { _, response -> String in + try response.sessionId( + sodium: sodium.wrappedValue, + nameBytes: nameAsData, + nameHashBytes: nameHash + ) + } + .retry(4) + .eraseToAnyPublisher() + } + } + ) + .collect() + .tryMap { results -> String in + guard results.count == validationCount, Set(results).count == 1 else { + throw SnodeAPIError.validationFailed + } + + return results[0] + } + .eraseToAnyPublisher() + } + + public static func getSwarm( + for publicKey: String, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher, Error> { + loadSwarmIfNeeded(for: publicKey) + + if let cachedSwarm = swarmCache.wrappedValue[publicKey], cachedSwarm.count >= minSwarmSnodeCount { + return Just(cachedSwarm) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + SNLog("Getting swarm for: \((publicKey == getUserHexEncodedPublicKey()) ? "self" : publicKey).") + + return getRandomSnode() + .flatMap { snode in + SnodeAPI.send( + request: SnodeRequest( + endpoint: .getSwarm, + body: GetSwarmRequest(pubkey: publicKey) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .retry(4) + .eraseToAnyPublisher() + } + .map { _, responseData in parseSnodes(from: responseData) } + .handleEvents( + receiveOutput: { swarm in setSwarm(to: swarm, for: publicKey) } + ) + .eraseToAnyPublisher() + } + + // MARK: - Retrieve + + public static func poll( + namespaces: [SnodeAPI.Namespace], + refreshingConfigHashes: [String] = [], + from snode: Snode, + associatedWith publicKey: String, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<[SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + + return Just(()) + .setFailureType(to: Error.self) + .map { _ -> [SnodeAPI.Namespace: String] in + namespaces + .reduce(into: [:]) { result, namespace in + guard namespace.shouldFetchSinceLastHash else { return } + + // Prune expired message hashes for this namespace on this service node + SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo( + for: snode, + namespace: namespace, + associatedWith: publicKey + ) + + result[namespace] = SnodeReceivedMessageInfo + .fetchLastNotExpired( + for: snode, + namespace: namespace, + associatedWith: publicKey + )? + .hash + } + } + .flatMap { namespaceLastHash -> AnyPublisher<[SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)], Error> in + var requests: [SnodeAPI.BatchRequest.Info] = [] + + // If we have any config hashes to refresh TTLs then add those requests first + if !refreshingConfigHashes.isEmpty { + requests.append( + BatchRequest.Info( + request: SnodeRequest( + endpoint: .expire, + body: UpdateExpiryRequest( + messageHashes: refreshingConfigHashes, + expiryMs: UInt64( + SnodeAPI.currentOffsetTimestampMs() + + (30 * 24 * 60 * 60 * 1000) // 30 days + ), + extend: true, + pubkey: userX25519PublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey, + subkey: nil // TODO: Need to get this + ) + ), + responseType: UpdateExpiryResponse.self + ) + ) + } + + // Determine the maxSize each namespace in the request should take up + let namespaceMaxSizeMap: [SnodeAPI.Namespace: Int64] = SnodeAPI.Namespace.maxSizeMap(for: namespaces) + let fallbackSize: Int64 = (namespaceMaxSizeMap.values.min() ?? 1) + + // Add the various 'getMessages' requests + requests.append( + contentsOf: namespaces.map { namespace -> SnodeAPI.BatchRequest.Info in + // Check if this namespace requires authentication + guard namespace.requiresReadAuthentication else { + return BatchRequest.Info( + request: SnodeRequest( + endpoint: .getMessages, + body: LegacyGetMessagesRequest( + pubkey: publicKey, + lastHash: (namespaceLastHash[namespace] ?? ""), + namespace: namespace, + maxCount: nil, + maxSize: namespaceMaxSizeMap[namespace] + .defaulting(to: fallbackSize) + ) + ), + responseType: GetMessagesResponse.self + ) + } + + return BatchRequest.Info( + request: SnodeRequest( + endpoint: .getMessages, + body: GetMessagesRequest( + lastHash: (namespaceLastHash[namespace] ?? ""), + namespace: namespace, + pubkey: publicKey, + subkey: nil, // TODO: Need to get this + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey, + maxSize: namespaceMaxSizeMap[namespace] + .defaulting(to: fallbackSize) + ) + ), + responseType: GetMessagesResponse.self + ) + } + ) + + // Actually send the request + let responseTypes = requests.map { $0.responseType } + + return SnodeAPI + .send( + request: SnodeRequest( + endpoint: .batch, + body: BatchRequest(requests: requests) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: responseTypes, using: dependencies) + .map { (batchResponse: HTTP.BatchResponse) -> [SnodeAPI.Namespace: (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?)] in + let messageResponses: [HTTP.BatchSubResponse] = batchResponse.responses + .compactMap { $0 as? HTTP.BatchSubResponse } + + /// Since we have extended the TTL for a number of messages we need to make sure we update the local + /// `SnodeReceivedMessageInfo.expirationDateMs` values so we don't end up deleting them + /// incorrectly before they actually expire on the swarm + if + !refreshingConfigHashes.isEmpty, + let refreshTTLSubReponse: HTTP.BatchSubResponse = batchResponse + .responses + .first(where: { $0 is HTTP.BatchSubResponse }) + .asType(HTTP.BatchSubResponse.self), + let refreshTTLResponse: UpdateExpiryResponse = refreshTTLSubReponse.body, + let validResults: [String: [(hash: String, expiry: UInt64)]] = try? refreshTTLResponse.validResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: getUserHexEncodedPublicKey(), + validationData: refreshingConfigHashes + ), + let groupedExpiryResult: [UInt64: [String]] = validResults[snode.ed25519PublicKey]? + .grouped(by: \.expiry) + .mapValues({ groupedResults in groupedResults.map { $0.hash } }) + { + Storage.shared.writeAsync { db in + try groupedExpiryResult.forEach { updatedExpiry, hashes in + try SnodeReceivedMessageInfo + .filter(hashes.contains(SnodeReceivedMessageInfo.Columns.hash)) + .updateAll( + db, + SnodeReceivedMessageInfo.Columns.expirationDateMs + .set(to: updatedExpiry) + ) + } + } + } + + return zip(namespaces, messageResponses) + .reduce(into: [:]) { result, next in + guard let messageResponse: GetMessagesResponse = next.1.body else { return } + + let namespace: SnodeAPI.Namespace = next.0 + + result[namespace] = ( + info: next.1.responseInfo, + data: ( + messages: messageResponse.messages + .compactMap { rawMessage -> SnodeReceivedMessage? in + SnodeReceivedMessage( + snode: snode, + publicKey: publicKey, + namespace: namespace, + rawMessage: rawMessage + ) + }, + lastHash: namespaceLastHash[namespace] + ) + ) + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + /// **Note:** This is the direct request to retrieve messages so should be retrieved automatically from the `poll()` method, in order to call + /// this directly remove the `@available` line + @available(*, unavailable, message: "Avoid using this directly, use the pre-built `poll()` method instead") + public static func getMessages( + in namespace: SnodeAPI.Namespace, + from snode: Snode, + associatedWith publicKey: String, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<(info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?), Error> { + return Deferred { + Future { resolver in + // Prune expired message hashes for this namespace on this service node + SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo( + for: snode, + namespace: namespace, + associatedWith: publicKey + ) + + let maybeLastHash: String? = SnodeReceivedMessageInfo + .fetchLastNotExpired( + for: snode, + namespace: namespace, + associatedWith: publicKey + )? + .hash + + resolver(Result.success(maybeLastHash)) + } + } + .tryFlatMap { lastHash -> AnyPublisher<(info: ResponseInfoType, data: GetMessagesResponse?, lastHash: String?), Error> in + + guard namespace.requiresReadAuthentication else { + return SnodeAPI + .send( + request: SnodeRequest( + endpoint: .getMessages, + body: LegacyGetMessagesRequest( + pubkey: publicKey, + lastHash: (lastHash ?? ""), + namespace: namespace, + maxCount: nil, + maxSize: nil + ) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: GetMessagesResponse.self, using: dependencies) + .map { info, data in (info, data, lastHash) } + .eraseToAnyPublisher() + } + + guard let userED25519KeyPair: KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + throw SnodeAPIError.noKeyPair + } + + return SnodeAPI + .send( + request: SnodeRequest( + endpoint: .getMessages, + body: GetMessagesRequest( + lastHash: (lastHash ?? ""), + namespace: namespace, + pubkey: publicKey, + subkey: nil, + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: GetMessagesResponse.self, using: dependencies) + .map { info, data in (info, data, lastHash) } + .eraseToAnyPublisher() + } + .map { info, data, lastHash -> (info: ResponseInfoType, data: (messages: [SnodeReceivedMessage], lastHash: String?)?) in + return ( + info: info, + data: data.map { messageResponse -> (messages: [SnodeReceivedMessage], lastHash: String?) in + return ( + messages: messageResponse.messages + .compactMap { rawMessage -> SnodeReceivedMessage? in + SnodeReceivedMessage( + snode: snode, + publicKey: publicKey, + namespace: namespace, + rawMessage: rawMessage + ) + }, + lastHash: lastHash + ) + } + ) + } + .eraseToAnyPublisher() + } + + // MARK: - Store + + public static func sendMessage( + _ message: SnodeMessage, + in namespace: Namespace, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<(ResponseInfoType, SendMessagesResponse), Error> { + let publicKey: String = message.recipient + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + let sendTimestamp: UInt64 = UInt64(SnodeAPI.currentOffsetTimestampMs()) + + // Create a convenience method to send a message to an individual Snode + func sendMessage(to snode: Snode) throws -> AnyPublisher<(any ResponseInfoType, SendMessagesResponse), Error> { + guard namespace.requiresWriteAuthentication else { + return SnodeAPI + .send( + request: SnodeRequest( + endpoint: .sendMessage, + body: LegacySendMessagesRequest( + message: message, + namespace: namespace + ) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: SendMessagesResponse.self, using: dependencies) + .eraseToAnyPublisher() + } + + guard let userED25519KeyPair: KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { + throw SnodeAPIError.noKeyPair + } + + return SnodeAPI + .send( + request: SnodeRequest( + endpoint: .sendMessage, + body: SendMessageRequest( + message: message, + namespace: namespace, + subkey: nil, + timestampMs: sendTimestamp, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: SendMessagesResponse.self, using: dependencies) + .eraseToAnyPublisher() + } + + return getSwarm(for: publicKey) + .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<(ResponseInfoType, SendMessagesResponse), Error> in + try sendMessage(to: snode) + .tryMap { info, response -> (ResponseInfoType, SendMessagesResponse) in + try response.validateResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: userX25519PublicKey + ) + + return (info, response) + } + .eraseToAnyPublisher() + } + } + + public static func sendConfigMessages( + _ messages: [(message: SnodeMessage, namespace: Namespace)], + allObsoleteHashes: [String], + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher { + guard + !messages.isEmpty, + let recipient: String = messages.first?.message.recipient + else { + return Fail(error: SnodeAPIError.generic) + .eraseToAnyPublisher() + } + // TODO: Need to get either the closed group subKey or the userEd25519 key for auth + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + let publicKey: String = recipient + var requests: [SnodeAPI.BatchRequest.Info] = messages + .map { message, namespace in + // Check if this namespace requires authentication + guard namespace.requiresWriteAuthentication else { + return BatchRequest.Info( + request: SnodeRequest( + endpoint: .sendMessage, + body: LegacySendMessagesRequest( + message: message, + namespace: namespace + ) + ), + responseType: SendMessagesResponse.self + ) + } + + return BatchRequest.Info( + request: SnodeRequest( + endpoint: .sendMessage, + body: SendMessageRequest( + message: message, + namespace: namespace, + subkey: nil, // TODO: Need to get this + timestampMs: UInt64(SnodeAPI.currentOffsetTimestampMs()), + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: SendMessagesResponse.self + ) + } + + // If we had any previous config messages then we should delete them + if !allObsoleteHashes.isEmpty { + requests.append( + BatchRequest.Info( + request: SnodeRequest( + endpoint: .deleteMessages, + body: DeleteMessagesRequest( + messageHashes: allObsoleteHashes, + requireSuccessfulDeletion: false, + pubkey: userX25519PublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + responseType: DeleteMessagesResponse.self + ) + ) + } + + let responseTypes = requests.map { $0.responseType } + + return getSwarm(for: publicKey) + .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher in + SnodeAPI + .send( + request: SnodeRequest( + endpoint: .sequence, + body: BatchRequest(requests: requests) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .eraseToAnyPublisher() + .decoded(as: responseTypes, requireAllResults: false, using: dependencies) + .eraseToAnyPublisher() + } + } + + // MARK: - Edit + + public static func updateExpiry( + publicKey: String, + serverHashes: [String], + updatedExpiryMs: UInt64, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<[String: [(hash: String, expiry: UInt64)]], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + return getSwarm(for: publicKey) + .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: [(hash: String, expiry: UInt64)]], Error> in + SnodeAPI + .send( + request: SnodeRequest( + endpoint: .expire, + body: UpdateExpiryRequest( + messageHashes: serverHashes, + expiryMs: updatedExpiryMs, + pubkey: publicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey, + subkey: nil + ) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: UpdateExpiryResponse.self, using: dependencies) + .tryMap { _, response -> [String: [(hash: String, expiry: UInt64)]] in + try response.validResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: getUserHexEncodedPublicKey(), + validationData: serverHashes + ) + } + .eraseToAnyPublisher() + } + } + + public static func revokeSubkey( + publicKey: String, + subkeyToRevoke: String, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + return getSwarm(for: publicKey) + .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher in + SnodeAPI + .send( + request: SnodeRequest( + endpoint: .revokeSubkey, + body: RevokeSubkeyRequest( + subkeyToRevoke: subkeyToRevoke, + pubkey: publicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: RevokeSubkeyResponse.self, using: dependencies) + .tryMap { _, response -> Void in + try response.validateResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: getUserHexEncodedPublicKey(), + validationData: subkeyToRevoke + ) + + return () + } + .eraseToAnyPublisher() + } + } + + // MARK: Delete + + public static func deleteMessages( + publicKey: String, + serverHashes: [String], + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<[String: Bool], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + + return getSwarm(for: publicKey) + .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: Bool], Error> in + SnodeAPI + .send( + request: SnodeRequest( + endpoint: .deleteMessages, + body: DeleteMessagesRequest( + messageHashes: serverHashes, + requireSuccessfulDeletion: false, + pubkey: userX25519PublicKey, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + to: snode, + associatedWith: publicKey, + using: dependencies + ) + .decoded(as: DeleteMessagesResponse.self, using: dependencies) + .tryMap { _, response -> [String: Bool] in + let validResultMap: [String: Bool] = try response.validResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: userX25519PublicKey, + validationData: serverHashes + ) + + // If `validResultMap` didn't throw then at least one service node + // deleted successfully so we should mark the hash as invalid so we + // don't try to fetch updates using that hash going forward (if we + // do we would end up re-fetching all old messages) + Storage.shared.writeAsync { db in + try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: serverHashes + ) + } + + return validResultMap + } + .eraseToAnyPublisher() + } + } + + /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. + public static func deleteAllMessages( + namespace: SnodeAPI.Namespace, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<[String: Bool], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + + return getSwarm(for: userX25519PublicKey) + .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: Bool], Error> in + getNetworkTime(from: snode) + .flatMap { timestampMs -> AnyPublisher<[String: Bool], Error> in + SnodeAPI + .send( + request: SnodeRequest( + endpoint: .deleteAll, + body: DeleteAllMessagesRequest( + namespace: namespace, + pubkey: userX25519PublicKey, + timestampMs: timestampMs, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + to: snode, + associatedWith: nil, + using: dependencies + ) + .decoded(as: DeleteAllMessagesResponse.self, using: dependencies) + .tryMap { _, response -> [String: Bool] in + try response.validResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: userX25519PublicKey, + validationData: timestampMs + ) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + } + + /// Clears all the user's data from their swarm. Returns a dictionary of snode public key to deletion confirmation. + public static func deleteAllMessages( + beforeMs: UInt64, + namespace: SnodeAPI.Namespace, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<[String: Bool], Error> { + guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { + return Fail(error: SnodeAPIError.noKeyPair) + .eraseToAnyPublisher() + } + + let userX25519PublicKey: String = getUserHexEncodedPublicKey() + + return getSwarm(for: userX25519PublicKey) + .tryFlatMapWithRandomSnode(retry: maxRetryCount) { snode -> AnyPublisher<[String: Bool], Error> in + getNetworkTime(from: snode) + .flatMap { timestampMs -> AnyPublisher<[String: Bool], Error> in + SnodeAPI + .send( + request: SnodeRequest( + endpoint: .deleteAllBefore, + body: DeleteAllBeforeRequest( + beforeMs: beforeMs, + namespace: namespace, + pubkey: userX25519PublicKey, + timestampMs: timestampMs, + ed25519PublicKey: userED25519KeyPair.publicKey, + ed25519SecretKey: userED25519KeyPair.secretKey + ) + ), + to: snode, + associatedWith: nil, + using: dependencies + ) + .decoded(as: DeleteAllBeforeResponse.self, using: dependencies) + .tryMap { _, response -> [String: Bool] in + try response.validResultMap( + sodium: sodium.wrappedValue, + userX25519PublicKey: userX25519PublicKey, + validationData: beforeMs + ) + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + } + + // MARK: - Internal API + + private static func getNetworkTime( + from snode: Snode, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher { + return SnodeAPI + .send( + request: SnodeRequest<[String: String]>( + endpoint: .getInfo, + body: [:] + ), + to: snode, + associatedWith: nil + ) + .decoded(as: GetNetworkTimestampResponse.self, using: dependencies) + .map { _, response in response.timestamp } + .eraseToAnyPublisher() + } + + internal static func getRandomSnode() -> AnyPublisher { + // randomElement() uses the system's default random generator, which is cryptographically secure + return getSnodePool() + .map { $0.randomElement()! } + .eraseToAnyPublisher() + } + + private static func getSnodePoolFromSeedNode( + dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher, Error> { + let request: SnodeRequest = SnodeRequest( + endpoint: .jsonGetNServiceNodes, + body: GetServiceNodesRequest( + activeOnly: true, + limit: 256, + fields: GetServiceNodesRequest.Fields( + publicIp: true, + storagePort: true, + pubkeyEd25519: true, + pubkeyX25519: true + ) + ) + ) + + guard let target: String = seedNodePool.randomElement() else { + return Fail(error: SnodeAPIError.snodePoolUpdatingFailed) + .eraseToAnyPublisher() + } + guard let payload: Data = try? JSONEncoder().encode(request) else { + return Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() + } + + SNLog("Populating snode pool using seed node: \(target).") + + return HTTP + .execute( + .post, + "\(target)/json_rpc", + body: payload, + useSeedNodeURLSession: true + ) + .decoded(as: SnodePoolResponse.self, using: dependencies) + .mapError { error in + switch error { + case HTTPError.parsingFailed: return SnodeAPIError.snodePoolUpdatingFailed + default: return error + } + } + .map { snodePool -> Set in + snodePool.result + .serviceNodeStates + .compactMap { $0.value } + .asSet() + } + .retry(2) + .handleEvents( + receiveCompletion: { result in + switch result { + case .finished: SNLog("Got snode pool from seed node: \(target).") + case .failure: SNLog("Failed to contact seed node at: \(target).") + } + } + ) + .eraseToAnyPublisher() + } + + private static func getSnodePoolFromSnode( + dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher, Error> { + var snodePool = SnodeAPI.snodePool.wrappedValue + var snodes: Set = [] + (0..<3).forEach { _ in + guard let snode = snodePool.randomElement() else { return } + + snodePool.remove(snode) + snodes.insert(snode) + } + + return Publishers + .MergeMany( + snodes + .map { snode -> AnyPublisher, Error> in + // 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. + SnodeAPI + .send( + request: SnodeRequest( + endpoint: .oxenDaemonRPCCall, + body: OxenDaemonRPCRequest( + endpoint: .daemonGetServiceNodes, + body: GetServiceNodesRequest( + activeOnly: true, + limit: nil, + fields: GetServiceNodesRequest.Fields( + publicIp: true, + storagePort: true, + pubkeyEd25519: true, + pubkeyX25519: true + ) + ) + ) + ), + to: snode, + associatedWith: nil + ) + .decoded(as: SnodePoolResponse.self, using: dependencies) + .mapError { error -> Error in + switch error { + case HTTPError.parsingFailed: + return SnodeAPIError.snodePoolUpdatingFailed + + default: return error + } + } + .map { _, snodePool -> Set in + snodePool.result + .serviceNodeStates + .compactMap { $0.value } + .asSet() + } + .retry(4) + .eraseToAnyPublisher() + } + ) + .collect() + .tryMap { results -> Set in + let result: Set = results.reduce(Set()) { prev, next in prev.intersection(next) } + + // We want the snodes to agree on at least this many snodes + guard result.count > 24 else { throw 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)) + } + .eraseToAnyPublisher() + } + + private static func send( + request: SnodeRequest, + to snode: Snode, + associatedWith publicKey: String?, + using dependencies: SSKDependencies = SSKDependencies() + ) -> AnyPublisher<(ResponseInfoType, Data?), Error> { + guard let payload: Data = try? JSONEncoder().encode(request) else { + return Fail(error: HTTPError.invalidJSON) + .eraseToAnyPublisher() + } + + guard Features.useOnionRequests else { + return HTTP + .execute( + .post, + "\(snode.address):\(snode.port)/storage_rpc/v1", + body: payload + ) + .map { response in (HTTP.ResponseInfo(code: -1, headers: [:]), response) } + .mapError { error in + switch error { + case HTTPError.httpRequestFailed(let statusCode, let data): + return (SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode, associatedWith: publicKey) ?? error) + + default: return error + } + } + .eraseToAnyPublisher() + } + + return dependencies.onionApi + .sendOnionRequest( + payload, + to: snode + ) + .mapError { error in + switch error { + case HTTPError.httpRequestFailed(let statusCode, let data): + return (SnodeAPI.handleError(withStatusCode: statusCode, data: data, forSnode: snode, associatedWith: publicKey) ?? error) + + default: return error + } + } + .handleEvents( + receiveOutput: { _, maybeData in + // Extract and store hard fork information if returned + guard + let data: Data = maybeData, + let snodeResponse: SnodeResponse = try? JSONDecoder() + .decode(SnodeResponse.self, from: data) + else { return } + + if snodeResponse.hardFork[1] > softfork { + softfork = snodeResponse.hardFork[1] + UserDefaults.standard[.softfork] = softfork + } + + if snodeResponse.hardFork[0] > hardfork { + hardfork = snodeResponse.hardFork[0] + UserDefaults.standard[.hardfork] = hardfork + softfork = snodeResponse.hardFork[1] + UserDefaults.standard[.softfork] = softfork + } + } + ) + .eraseToAnyPublisher() + } + + // MARK: - Parsing + + // 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 responseData: Data?) -> Set { + guard + let responseData: Data = responseData, + let responseJson: JSON = try? JSONSerialization.jsonObject( + with: responseData, + options: [ .fragmentsAllowed ] + ) as? JSON + else { + SNLog("Failed to parse snodes from response data.") + return [] + } + guard let rawSnodes = responseJson["snodes"] as? [JSON] else { + SNLog("Failed to parse snodes from: \(responseJson).") + return [] + } + + guard let snodeData: Data = try? JSONSerialization.data(withJSONObject: rawSnodes, options: []) else { + return [] + } + + // FIXME: Hopefully at some point this different Snode structure will be deprecated and can be removed + if + let swarmSnodes: [SwarmSnode] = try? JSONDecoder().decode([Failable].self, from: snodeData).compactMap({ $0.value }), + !swarmSnodes.isEmpty + { + return swarmSnodes.map { $0.toSnode() }.asSet() + } + + return ((try? JSONDecoder().decode([Failable].self, from: snodeData)) ?? []) + .compactMap { $0.value } + .asSet() + } + + // MARK: - Error Handling + + @discardableResult + internal static func handleError( + withStatusCode statusCode: UInt, + data: Data?, + forSnode snode: Snode, + associatedWith publicKey: String? = nil + ) -> Error? { + func handleBadSnode() { + let oldFailureCount = (SnodeAPI.snodeFailureCount.wrappedValue[snode] ?? 0) + let newFailureCount = oldFailureCount + 1 + SnodeAPI.snodeFailureCount.mutate { $0[snode] = newFailureCount } + SNLog("Couldn't reach snode at: \(snode); setting failure count to \(newFailureCount).") + if newFailureCount >= SnodeAPI.snodeFailureThreshold { + SNLog("Failure threshold reached for: \(snode); dropping it.") + if let publicKey = publicKey { + SnodeAPI.dropSnodeFromSwarmIfNeeded(snode, publicKey: publicKey) + } + SnodeAPI.dropSnodeFromSnodePool(snode) + SNLog("Snode pool count: \(snodePool.wrappedValue.count).") + SnodeAPI.snodeFailureCount.mutate { $0[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 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 { + SNLog("Got a 421 without an associated public key.") + } + + default: + handleBadSnode() + SNLog("Unhandled response code: \(statusCode).") + } + + return nil + } +} + +@objc(SNSnodeAPI) +public final class SNSnodeAPI: NSObject { + @objc(currentOffsetTimestampMs) + public static func currentOffsetTimestampMs() -> UInt64 { + return UInt64(SnodeAPI.currentOffsetTimestampMs()) + } +} + +// MARK: - Convenience + +public extension Publisher where Output == Set { + func tryFlatMapWithRandomSnode( + maxPublishers: Subscribers.Demand = .unlimited, + retry retries: Int = 0, + _ transform: @escaping (Snode) throws -> P + ) -> AnyPublisher where T == P.Output, P: Publisher, P.Failure == Error { + return self + .mapError { $0 } + .flatMap(maxPublishers: maxPublishers) { swarm -> AnyPublisher in + var remainingSnodes: Set = swarm + + return Just(()) + .setFailureType(to: Error.self) + .tryFlatMap(maxPublishers: maxPublishers) { _ -> AnyPublisher in + let snode: Snode = try remainingSnodes.popRandomElement() ?? { throw SnodeAPIError.generic }() + + return try transform(snode) + .eraseToAnyPublisher() + } + .retry(retries) + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} diff --git a/SessionSnodeKit/OnionRequestAPI+Encryption.swift b/SessionSnodeKit/OnionRequestAPI+Encryption.swift deleted file mode 100644 index 8652b28d8..000000000 --- a/SessionSnodeKit/OnionRequestAPI+Encryption.swift +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import CryptoSwift -import PromiseKit -import SessionUtilitiesKit - -internal extension OnionRequestAPI { - - static func encode(ciphertext: Data, json: JSON) throws -> Data { - // The encoding of V2 onion requests looks like: | 4 bytes: size N of ciphertext | N bytes: ciphertext | json as utf8 | - guard JSONSerialization.isValidJSONObject(json) else { throw HTTP.Error.invalidJSON } - let jsonAsData = try JSONSerialization.data(withJSONObject: json, options: [ .fragmentsAllowed ]) - let ciphertextSize = Int32(ciphertext.count).littleEndian - let ciphertextSizeAsData = withUnsafePointer(to: ciphertextSize) { Data(bytes: $0, count: MemoryLayout.size) } - return ciphertextSizeAsData + ciphertext + jsonAsData - } - - /// Encrypts `payload` for `destination` and returns the result. Use this to build the core of an onion request. - static func encrypt(_ payload: Data, for destination: OnionRequestAPIDestination) -> Promise { - let (promise, seal) = Promise.pending() - DispatchQueue.global(qos: .userInitiated).async { - do { - switch destination { - case .snode(let snode): - // Need to wrap the payload for snode requests - let data: Data = try encode(ciphertext: payload, json: [ "headers" : "" ]) - let result: AESGCM.EncryptionResult = try AESGCM.encrypt(data, for: snode.x25519PublicKey) - seal.fulfill(result) - - case .server(_, _, let serverX25519PublicKey, _, _): - let result: AESGCM.EncryptionResult = try AESGCM.encrypt(payload, for: serverX25519PublicKey) - seal.fulfill(result) - } - } - 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: OnionRequestAPIDestination, to rhs: OnionRequestAPIDestination, using previousEncryptionResult: AESGCM.EncryptionResult) -> Promise { - let (promise, seal) = Promise.pending() - - DispatchQueue.global(qos: .userInitiated).async { - var parameters: JSON - - switch rhs { - case .snode(let snode): - let snodeED25519PublicKey = snode.ed25519PublicKey - parameters = [ "destination" : snodeED25519PublicKey ] - - case .server(let host, let target, _, let scheme, let port): - let scheme = scheme ?? "https" - let port = port ?? (scheme == "https" ? 443 : 80) - parameters = [ "host" : host, "target" : target, "method" : "POST", "protocol" : scheme, "port" : port ] - } - - parameters["ephemeral_key"] = previousEncryptionResult.ephemeralPublicKey.toHexString() - - let x25519PublicKey: String - - switch lhs { - case .snode(let snode): - let snodeX25519PublicKey = snode.x25519PublicKey - x25519PublicKey = snodeX25519PublicKey - - case .server(_, _, let serverX25519PublicKey, _, _): - x25519PublicKey = serverX25519PublicKey - } - - do { - let plaintext = try encode(ciphertext: previousEncryptionResult.ciphertext, json: parameters) - let result = try AESGCM.encrypt(plaintext, for: x25519PublicKey) - seal.fulfill(result) - } - catch (let error) { - seal.reject(error) - } - } - - return promise - } -} diff --git a/SessionSnodeKit/OnionRequestAPI.swift b/SessionSnodeKit/OnionRequestAPI.swift deleted file mode 100644 index eedb629aa..000000000 --- a/SessionSnodeKit/OnionRequestAPI.swift +++ /dev/null @@ -1,789 +0,0 @@ -// 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?, timeout: TimeInterval) -> Promise - static func sendOnionRequest(_ request: URLRequest, to server: String, using version: OnionRequestAPIVersion, with x25519PublicKey: String, timeout: TimeInterval) -> Promise<(OnionRequestResponseInfoType, Data?)> -} - -public extension OnionRequestAPIType { - static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String?) -> Promise { - sendOnionRequest(to: snode, invoking: method, with: parameters, associatedWith: publicKey, timeout: HTTP.timeout) - } - - static func sendOnionRequest(_ request: URLRequest, to server: String, with x25519PublicKey: String, timeout: TimeInterval = HTTP.timeout) -> Promise<(OnionRequestResponseInfoType, Data?)> { - sendOnionRequest(request, to: server, using: .v4, with: x25519PublicKey, timeout: timeout) - } -} - -/// See the "Onion Requests" section of [The Session Whitepaper](https://arxiv.org/pdf/2002.04609.pdf) for more information. -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: [[Snode]: UInt] = [:] - - /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - private static var snodeFailureCount: [Snode: UInt] = [:] - - /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - public static var guardSnodes: Set = [] - - // 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 - - public static let maxRequestSize = 10_000_000 // 10 MB - /// The number of snodes (including the guard snode) in a path. - private static let pathSize: UInt = 3 - /// The number of times a path can fail before it's replaced. - private static let pathFailureThreshold: UInt = 3 - /// The number of times a snode can fail before it's replaced. - private static let snodeFailureThreshold: UInt = 3 - /// The number of paths to maintain. - public static let targetPathCount: UInt = 2 - - /// The number of guard snodes required to maintain `targetPathCount` paths. - private static var targetGuardSnodeCount: UInt { return targetPathCount } // One per path - - // MARK: - Onion Building Result - - private typealias OnionBuildingResult = (guardSnode: Snode, finalEncryptionResult: AESGCM.EncryptionResult, destinationSymmetricKey: Data) - - // MARK: - Private API - /// Tests the given snode. The returned promise errors out if the snode is faulty; the promise is fulfilled otherwise. - private static func testSnode(_ snode: Snode) -> Promise { - let (promise, seal) = Promise.pending() - - DispatchQueue.global(qos: .userInitiated).async { - let url = "\(snode.address):\(snode.port)/get_stats/v1" - let timeout: TimeInterval = 3 // Use a shorter timeout for testing - - HTTP.execute(.get, url, timeout: timeout) - .done2 { 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) - } - } - - return promise - } - - /// Finds `targetGuardSnodeCount` guard snodes to use for path building. The returned promise errors out with `Error.insufficientSnodes` - /// if not enough (reliable) snodes are available. - private static func getGuardSnodes(reusing reusableGuardSnodes: [Snode]) -> Promise> { - if guardSnodes.count >= targetGuardSnodeCount { - return Promise> { $0.fulfill(guardSnodes) } - } - else { - SNLog("Populating guard snode cache.") - // Sync on LokiAPI.workQueue - var unusedSnodes = SnodeAPI.snodePool.wrappedValue.subtracting(reusableGuardSnodes) - let reusableGuardSnodeCount = UInt(reusableGuardSnodes.count) - - 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(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() - } - - return when(fulfilled: promises).map2 { guardSnodes in - let guardSnodesAsSet = Set(guardSnodes + reusableGuardSnodes) - OnionRequestAPI.guardSnodes = guardSnodesAsSet - - return guardSnodesAsSet - } - } - } - - /// Builds and returns `targetPathCount` paths. The returned promise errors out with `Error.insufficientSnodes` - /// if not enough (reliable) snodes are available. - @discardableResult - private static func buildPaths(reusing reusablePaths: [[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<[[Snode]]> = getGuardSnodes(reusing: reusableGuardSnodes) - .map2 { guardSnodes -> [[Snode]] in - var unusedSnodes = SnodeAPI.snodePool.wrappedValue - .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 - } - } - .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 - } - - promise.done2 { _ in buildPathsPromise = nil } - promise.catch2 { _ in buildPathsPromise = nil } - buildPathsPromise = promise - return promise - } - - /// Returns a `Path` to be used for building an onion request. Builds new paths as needed. - private static func getPath(excluding snode: Snode?) -> Promise<[Snode]> { - guard pathSize >= 1 else { preconditionFailure("Can't build path of size zero.") } - - 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, - 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 { - return buildPaths(reusing: paths).map2 { paths in - 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 - - 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 - } - - throw OnionRequestAPIError.insufficientSnodes - } - - guard let path: [Snode] = paths.randomElement() else { - throw OnionRequestAPIError.insufficientSnodes - } - - return path - } - } - } - - private static func dropGuardSnode(_ snode: Snode) { - #if DEBUG - dispatchPrecondition(condition: .onQueue(Threading.workQueue)) - #endif - guardSnodes = guardSnodes.filter { $0 != snode } - } - - private static func drop(_ snode: Snode) throws { - #if DEBUG - dispatchPrecondition(condition: .onQueue(Threading.workQueue)) - #endif - // We repair the path here because we can do it sync. In the case where we drop a whole - // path we leave the re-building up to getPath(excluding:) because re-building the path - // in that case is async. - OnionRequestAPI.snodeFailureCount[snode] = 0 - var oldPaths = paths - guard let pathIndex = oldPaths.firstIndex(where: { $0.contains(snode) }) else { return } - var path = oldPaths[pathIndex] - guard let snodeIndex = path.firstIndex(of: snode) else { return } - path.remove(at: snodeIndex) - let unusedSnodes = SnodeAPI.snodePool.wrappedValue.subtracting(oldPaths.flatMap { $0 }) - 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 - - Storage.shared.write { db in - SNLog("Persisting onion request paths to database.") - try? newPaths.save(db) - } - } - - private static func drop(_ path: [Snode]) { - #if DEBUG - dispatchPrecondition(condition: .onQueue(Threading.workQueue)) - #endif - OnionRequestAPI.pathFailureCount[path] = 0 - var paths = OnionRequestAPI.paths - guard let pathIndex = paths.firstIndex(of: path) else { return } - paths.remove(at: pathIndex) - OnionRequestAPI.paths = paths - - Storage.shared.write { db in - guard !paths.isEmpty else { - SNLog("Clearing onion request paths.") - 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: 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 { - 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() - } - } - .map2 { _ in (guardSnode, encryptionResult, targetSnodeSymmetricKey) } - } - - // MARK: - Public API - - /// Sends an onion request to `snode`. Builds new paths as needed. - public static func sendOnionRequest(to snode: Snode, invoking method: SnodeAPIEndpoint, with parameters: JSON, associatedWith publicKey: String? = nil, timeout: TimeInterval = HTTP.timeout) -> 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, timeout: timeout) - .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: URLRequest, to server: String, using version: OnionRequestAPIVersion = .v4, with x25519PublicKey: String, timeout: TimeInterval = HTTP.timeout) -> Promise<(OnionRequestResponseInfoType, Data?)> { - guard let url = request.url, let host = request.url?.host else { - return Promise(error: OnionRequestAPIError.invalidURL) - } - - let scheme: String? = url.scheme - let port: UInt16? = url.port.map { UInt16($0) } - - guard let payload: Data = generatePayload(for: request, with: version) else { - return Promise(error: OnionRequestAPIError.invalidRequestInfo) - } - - let destination = OnionRequestAPIDestination.server( - host: host, - target: version.rawValue, - x25519PublicKey: x25519PublicKey, - scheme: scheme, - port: port - ) - let promise = sendOnionRequest(with: payload, to: destination, version: version, timeout: timeout) - promise.catch2 { error in - SNLog("Couldn't reach server: \(url) due to error: \(error).") - } - return promise - } - - public static func sendOnionRequest(with payload: Data, to destination: OnionRequestAPIDestination, version: OnionRequestAPIVersion, timeout: TimeInterval = HTTP.timeout) -> 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 OnionRequestAPIDestination.server = destination, Double(onion.count) > 0.75 * Double(maxRequestSize) { - SNLog("Approaching request size limit: ~\(onion.count) bytes.") - } - let parameters: JSON = [ - "ephemeral_key" : finalEncryptionResult.ephemeralPublicKey.toHexString() - ] - let body: Data - do { - body = try encode(ciphertext: onion, json: parameters) - } catch { - return seal.reject(error) - } - let destinationSymmetricKey = intermediate.destinationSymmetricKey - - HTTP.execute(.post, url, body: body, timeout: timeout) - .done2 { responseData in - handleResponse( - responseData: responseData, - destinationSymmetricKey: destinationSymmetricKey, - version: version, - destination: destination, - seal: seal - ) - } - .catch2 { error in - seal.reject(error) - } - } - .catch2 { error in - seal.reject(error) - } - } - - promise.catch2 { error in // Must be invoked on Threading.workQueue - 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, data: data, forSnode: snode) // Intentionally don't throw - } - - drop(path) - } - 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, data: data, forSnode: snode) // Intentionally don't throw - do { - try drop(snode) - } - catch { - handleUnspecificError() - } - } - else { - OnionRequestAPI.snodeFailureCount[snode] = snodeFailureCount - } - } else { - // Do nothing - } - } - else if let message = json?["result"] as? String, message == "Loki Server error" { - // Do nothing - } - else if case .server(let host, _, _, _, _) = destination, host == "116.203.70.33" && statusCode == 0 { - // FIXME: Temporary thing to kick out nodes that can't talk to the V2 OGS yet - handleUnspecificError() - } - else if statusCode == 0 { // Timeout - // Do nothing - } - else { - handleUnspecificError() - } - } - - return promise - } - - // 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.clockOffsetMs.mutate { $0 = 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) - - // Process the bencoded response - guard let processedResponse: (info: ResponseInfo, body: Data?) = process(bencodedData: data) else { - return seal.reject(HTTP.Error.invalidResponse) - } - - // Custom handle a clock out of sync error (v4 returns '425' but included the '406' - // just in case) - guard processedResponse.info.code != 406 && processedResponse.info.code != 425 else { - SNLog("The user's clock is out of sync with the service node network.") - return seal.reject(SnodeAPIError.clockOutOfSync) - } - - guard processedResponse.info.code != 401 else { // Signature verification failed - SNLog("Failed to verify the signature.") - return seal.reject(SnodeAPIError.signatureVerificationFailed) - } - - // Handle error status codes - guard 200...299 ~= processedResponse.info.code else { - return seal.reject( - OnionRequestAPIError.httpRequestFailedAtDestination( - statusCode: UInt(processedResponse.info.code), - data: data, - destination: destination - ) - ) - } - - return seal.fulfill(processedResponse) - } - catch { - return seal.reject(error) - } - } - } - - public static func process(bencodedData data: Data) -> (info: ResponseInfo, body: Data?)? { - // 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 nil - } - - 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 nil - } - - 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 (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 nil - } - - let dataBytes: Array = Array(data) - let dataEndIndex: Int = (dataBytes.count - suffixData.count) - let dataStartIndex: Int = (dataEndIndex - finalDataLength) - let finalDataBytes: ArraySlice = dataBytes[dataStartIndex.. + public var onionApi: OnionRequestAPIType.Type { + get { Dependencies.getValueSettingIfNull(&_onionApi) { OnionRequestAPI.self } } + set { _onionApi.mutate { $0 = newValue } } + } + + // MARK: - Initialization + + public init( + subscribeQueue: DispatchQueue? = nil, + receiveQueue: DispatchQueue? = nil, + onionApi: OnionRequestAPIType.Type? = nil, + generalCache: MutableGeneralCacheType? = nil, + storage: Storage? = nil, + scheduler: ValueObservationScheduler? = nil, + standardUserDefaults: UserDefaultsType? = nil, + date: Date? = nil + ) { + _onionApi = Atomic(onionApi) + + super.init( + subscribeQueue: subscribeQueue, + receiveQueue: receiveQueue, + generalCache: generalCache, + storage: storage, + scheduler: scheduler, + standardUserDefaults: standardUserDefaults, + date: date + ) + } +} diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift deleted file mode 100644 index d3a542f03..000000000 --- a/SessionSnodeKit/SnodeAPI.swift +++ /dev/null @@ -1,1135 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import PromiseKit -import Sodium -import GRDB -import SessionUtilitiesKit - -public final class SnodeAPI { - private static let sodium = Sodium() - - private static var hasLoadedSnodePool: Atomic = Atomic(false) - private static var loadedSwarms: Atomic> = Atomic([]) - private static var getSnodePoolPromise: Atomic>?> = Atomic(nil) - - /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - internal static var snodeFailureCount: Atomic<[Snode: UInt]> = Atomic([:]) - /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - internal static var snodePool: Atomic> = Atomic([]) - - /// The offset between the user's clock and the Service Node's clock. Used in cases where the - /// user's clock is incorrect - public static var clockOffsetMs: Atomic = Atomic(0) - - public static func currentOffsetTimestampMs() -> Int64 { - return ( - Int64(floor(Date().timeIntervalSince1970 * 1000)) + - SnodeAPI.clockOffsetMs.wrappedValue - ) - } - - /// - Note: Should only be accessed from `Threading.workQueue` to avoid race conditions. - public static var swarmCache: Atomic<[String: Set]> = Atomic([:]) - - // MARK: - Namespaces - - public static let defaultNamespace = 0 - public static let closedGroupNamespace = -10 - public static let configNamespace = 5 - - // MARK: - Hardfork version - - public static var hardfork = UserDefaults.standard[.hardfork] - public static var softfork = UserDefaults.standard[.softfork] - - // 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://seed1.getsession.org:4432", - "https://seed2.getsession.org:4432", - "https://seed3.getsession.org:4432" - ] - ) - private static let snodeFailureThreshold = 3 - private static let targetSwarmSnodeCount = 2 - private static let minSnodePoolCount = 12 - - // MARK: Snode Pool Interaction - - private static var hasInsufficientSnodes: Bool { snodePool.wrappedValue.count < minSnodePoolCount } - - private static func loadSnodePoolIfNeeded() { - guard !hasLoadedSnodePool.wrappedValue else { return } - - let fetchedSnodePool: Set = Storage.shared - .read { db in try Snode.fetchSet(db) } - .defaulting(to: []) - - snodePool.mutate { $0 = fetchedSnodePool } - hasLoadedSnodePool.mutate { $0 = true } - } - - private static func setSnodePool(to newValue: Set, db: Database? = nil) { - snodePool.mutate { $0 = newValue } - - 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) } - } - } - } - - private static func dropSnodeFromSnodePool(_ snode: Snode) { - #if DEBUG - dispatchPrecondition(condition: .onQueue(Threading.workQueue)) - #endif - var snodePool = SnodeAPI.snodePool.wrappedValue - snodePool.remove(snode) - setSnodePool(to: snodePool) - } - - @objc public static func clearSnodePool() { - snodePool.mutate { $0.removeAll() } - - Threading.workQueue.async { - setSnodePool(to: []) - } - } - - // MARK: Swarm Interaction - private static func loadSwarmIfNeeded(for publicKey: String) { - guard !loadedSwarms.wrappedValue.contains(publicKey) else { return } - - let updatedCacheForKey: Set = Storage.shared - .read { db in try Snode.fetchSet(db, publicKey: publicKey) } - .defaulting(to: []) - - swarmCache.mutate { $0[publicKey] = updatedCacheForKey } - loadedSwarms.mutate { $0.insert(publicKey) } - } - - private static func setSwarm(to newValue: Set, for publicKey: String, persist: Bool = true) { - #if DEBUG - dispatchPrecondition(condition: .onQueue(Threading.workQueue)) - #endif - swarmCache.mutate { $0[publicKey] = newValue } - - guard persist else { return } - - Storage.shared.write { db in - try? newValue.save(db, key: publicKey) - } - } - - public static func dropSnodeFromSwarmIfNeeded(_ snode: Snode, publicKey: String) { - #if DEBUG - dispatchPrecondition(condition: .onQueue(Threading.workQueue)) - #endif - let swarmOrNil = swarmCache.wrappedValue[publicKey] - guard var swarm = swarmOrNil, let index = swarm.firstIndex(of: snode) else { return } - swarm.remove(at: index) - setSwarm(to: swarm, for: publicKey) - } - - // MARK: Internal API - - 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 { 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 - softfork = hf[1] - UserDefaults.standard[.softfork] = softfork - } - } - - return responseData - } - } - else { - let url = "\(snode.address):\(snode.port)/storage_rpc/v1" - 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 { 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 - } - } - - internal static func getRandomSnode() -> Promise { - // randomElement() uses the system's default random generator, which is cryptographically secure - return getSnodePool().map2 { $0.randomElement()! } - } - - private static func getSnodePoolFromSeedNode() -> Promise> { - 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 - ] - ] - ] - 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 { responseData -> Set in - guard let snodePool: SnodePoolResponse = try? JSONDecoder().decode(SnodePoolResponse.self, from: responseData) else { - throw SnodeAPIError.snodePoolUpdatingFailed - } - - 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 - SNLog("Failed to contact seed node at: \(target).") - seal.reject(error) - } - } - - return promise - } - - private static func getSnodePoolFromSnode() -> Promise> { - var snodePool = SnodeAPI.snodePool.wrappedValue - var snodes: Set = [] - (0..<3).forEach { _ in - 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 - ] - ] - ] - - 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 snodePool.result - .serviceNodeStates - .compactMap { $0.value } - .asSet() - } - } - } - - let promise = when(fulfilled: snodePoolPromises).map2 { results -> Set in - let result: Set = results.reduce(Set()) { prev, next in prev.intersection(next) } - - // We want the snodes to agree on at least this many snodes - guard result.count > 24 else { throw 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 - - public static func hasCachedSnodesInclusingExpired() -> Bool { - loadSnodePoolIfNeeded() - - return !hasInsufficientSnodes - } - - public static func getSnodePool() -> Promise> { - loadSnodePoolIfNeeded() - let now = Date() - let hasSnodePoolExpired: Bool = Storage.shared[.lastSnodePoolRefreshDate] - .map { now.timeIntervalSince($0) > 2 * 60 * 60 } - .defaulting(to: true) - let snodePool: Set = SnodeAPI.snodePool.wrappedValue - - guard hasInsufficientSnodes || hasSnodePoolExpired else { - return Promise.value(snodePool) - } - - if let getSnodePoolPromise = getSnodePoolPromise.wrappedValue { return getSnodePoolPromise } - - return getSnodePoolPromise.mutate { result in - /// It was possible for multiple threads to call this at the same time resulting in duplicate promises getting created, while - /// this should no longer be possible (as the `wrappedValue` should now properly be blocked) this is a sanity check - /// to make sure we don't create an additional promise when one already exists - if let previouslyBlockedPromise: Promise> = result { - return previouslyBlockedPromise - } - - let promise: Promise> - - if snodePool.count < minSnodePoolCount { - promise = getSnodePoolFromSeedNode() - } - else { - promise = getSnodePoolFromSnode().recover2 { _ in - getSnodePoolFromSeedNode() - } - } - - 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.mutate { $0 = nil } - } - promise.catch2 { _ in - getSnodePoolPromise.mutate { $0 = nil } - } - - result = promise - return promise - } - } - - public static func getSessionID(for onsName: String) -> Promise { - let validationCount = 3 - let sessionIDByteCount = 33 - // The name must be lowercased - 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: SnodeAPIError.hashingFailed) - } - - // Ask 3 different snodes for the Session ID associated with the given name hash - let base64EncodedNameHash = nameHash.toBase64() - let parameters: [String:Any] = [ - "endpoint" : "ons_resolve", - "params" : [ - "type" : 0, // type 0 means Session - "name_hash" : base64EncodedNameHash - ] - ] - let promises = (0...pending() - - when(resolved: promises).done2 { results in - var sessionIDs: [String] = [] - for result in results { - switch result { - case .rejected(let error): return seal.reject(error) - - case .fulfilled(let responseData): - guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { - throw HTTP.Error.invalidJSON - } - guard - let intermediate = responseJson["result"] as? JSON, - let hexEncodedCiphertext = intermediate["encrypted_value"] as? String - else { return seal.reject(HTTP.Error.invalidJSON) } - - let ciphertext = [UInt8](Data(hex: hexEncodedCiphertext)) - let isArgon2Based = (intermediate["nonce"] == nil) - - if isArgon2Based { - // Handle old Argon2-based encryption used before HF16 - let salt = [UInt8](Data(repeating: 0, count: sodium.pwHash.SaltBytes)) - guard - let key = sodium.pwHash.hash( - outputLength: sodium.secretBox.KeyBytes, - passwd: nameAsData, - salt: salt, - opsLimit: sodium.pwHash.OpsLimitModerate, - memLimit: sodium.pwHash.MemLimitModerate, - alg: .Argon2ID13 - ) - else { return seal.reject(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()) - } - 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 sessionIDs.count == validationCount && Set(sessionIDs).count == 1 else { - return seal.reject(SnodeAPIError.validationFailed) - } - - seal.fulfill(sessionIDs.first!) - } - - return promise - } - - public static func getTargetSnodes(for publicKey: String) -> Promise<[Snode]> { - // shuffled() uses the system's default random generator, which is cryptographically secure - return getSwarm(for: publicKey).map2 { Array($0.shuffled().prefix(targetSwarmSnodeCount)) } - } - - public static func getSwarm(for publicKey: String) -> Promise> { - loadSwarmIfNeeded(for: publicKey) - - if let cachedSwarm = swarmCache.wrappedValue[publicKey], cachedSwarm.count >= minSwarmSnodeCount { - return Promise> { $0.fulfill(cachedSwarm) } - } - - 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 { responseData in - let swarm = parseSnodes(from: responseData) - - setSwarm(to: swarm, for: publicKey) - return swarm - } - } - - // MARK: - Retrieve - - // Not in use until we can batch delete and store config messages - public static func getConfigMessages(from snode: Snode, associatedWith publicKey: String) -> Promise<([SnodeReceivedMessage], String?)> { - let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.pending() - - 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], String?)> { - let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.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], String?)> { - let (promise, seal) = Promise<([SnodeReceivedMessage], String?)>.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], String?)> { - /// **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) - } - - // Get last message hash - SnodeReceivedMessageInfo.pruneExpiredMessageHashInfo(for: snode, namespace: namespace, associatedWith: publicKey) - let lastHash = SnodeReceivedMessageInfo.fetchLastNotExpired(for: snode, namespace: namespace, associatedWith: publicKey)?.hash ?? "" - - // Construct signature - let timestamp = UInt64(SnodeAPI.currentOffsetTimestampMs()) - 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: SnodeAPIError.signingFailed) } - - // Make the request - let parameters: JSON = [ - "pubKey": Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey, - "namespace": namespace, - "lastHash": lastHash, - "timestamp": timestamp, - "pubkey_ed25519": ed25519PublicKey, - "signature": signature.toBase64() - ] - - 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 - ) - } - } - .map { ($0, lastHash) } - } - - private static func getMessagesUnauthenticated( - from snode: Snode, - associatedWith publicKey: String, - namespace: Int = closedGroupNamespace - ) -> Promise<([SnodeReceivedMessage], String?)> { - // Get last message hash - 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.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 - ) - } - } - .map { ($0, lastHash) } - } - - // MARK: Store - - 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>> { - guard - let messageData: Data = try? JSONEncoder().encode(message), - let messageJson: JSON = try? JSONSerialization.jsonObject(with: messageData, options: [ .fragmentsAllowed ]) as? JSON - else { return Promise(error: HTTP.Error.invalidJSON) } - - guard let userED25519KeyPair: Box.KeyPair = Storage.shared.read({ db in Identity.fetchUserEd25519KeyPair(db) }) else { - return Promise(error: SnodeAPIError.noKeyPair) - } - - // Construct signature - let timestamp = UInt64(SnodeAPI.currentOffsetTimestampMs()) - 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>> { - 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: 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 - } - .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 - - public static func deleteMessage(publicKey: String, serverHashes: [String]) -> Promise<[String: Bool]> { - guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { - return Promise(error: SnodeAPIError.noKeyPair) - } - - let publicKey = (Features.useTestnet ? publicKey.removingIdPrefixIfNeeded() : publicKey) - let userX25519PublicKey: String = getUserHexEncodedPublicKey() - - return attempt(maxRetryCount: maxRetryCount, recoveringOn: Threading.workQueue) { - getSwarm(for: publicKey) - .then2 { swarm -> Promise<[String: Bool]> in - // "delete" || messages... - let verificationBytes = SnodeAPIEndpoint.deleteMessage.rawValue.bytes - .appending(contentsOf: serverHashes.joined().bytes) - - guard - let snode = swarm.randomElement(), - let 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) - - 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 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 { - 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 - } - } - - // If we get to here then we assume it's been deleted from at least one - // service node and as a result we need to mark the hash as invalid so - // we don't try to fetch updates since that hash going forward (if we do - // we would end up re-fetching all old messages) - Storage.shared.writeAsync { db in - try? SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( - db, - potentiallyInvalidHashes: serverHashes - ) - } - - 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 - } - } - } - } - } - } - } - - // MARK: Parsing - - // 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 responseData: Data) -> Set { - guard let responseJson: JSON = try? JSONSerialization.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? JSON else { - SNLog("Failed to parse snodes from response data.") - return [] - } - guard let rawSnodes = responseJson["snodes"] as? [JSON] else { - SNLog("Failed to parse snodes from: \(responseJson).") - return [] - } - - guard let snodeData: Data = try? JSONSerialization.data(withJSONObject: rawSnodes, options: []) else { - return [] - } - - // FIXME: Hopefully at some point this different Snode structure will be deprecated and can be removed - if - let swarmSnodes: [SwarmSnode] = try? JSONDecoder().decode([Failable].self, from: snodeData).compactMap({ $0.value }), - !swarmSnodes.isEmpty - { - return swarmSnodes.map { $0.toSnode() }.asSet() - } - - return ((try? JSONDecoder().decode([Failable].self, from: snodeData)) ?? []) - .compactMap { $0.value } - .asSet() - } - - // MARK: Error Handling - - /// - Note: Should only be invoked from `Threading.workQueue` to avoid race conditions. - @discardableResult - 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 - func handleBadSnode() { - let oldFailureCount = (SnodeAPI.snodeFailureCount.wrappedValue[snode] ?? 0) - let newFailureCount = oldFailureCount + 1 - SnodeAPI.snodeFailureCount.mutate { $0[snode] = newFailureCount } - SNLog("Couldn't reach snode at: \(snode); setting failure count to \(newFailureCount).") - if newFailureCount >= SnodeAPI.snodeFailureThreshold { - SNLog("Failure threshold reached for: \(snode); dropping it.") - if let publicKey = publicKey { - SnodeAPI.dropSnodeFromSwarmIfNeeded(snode, publicKey: publicKey) - } - SnodeAPI.dropSnodeFromSnodePool(snode) - SNLog("Snode pool count: \(snodePool.wrappedValue.count).") - SnodeAPI.snodeFailureCount.mutate { $0[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 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 { - SNLog("Got a 421 without an associated public key.") - } - - default: - handleBadSnode() - SNLog("Unhandled response code: \(statusCode).") - } - - return nil - } -} - -@objc(SNSnodeAPI) -public final class SNSnodeAPI: NSObject { - @objc(currentOffsetTimestampMs) - public static func currentOffsetTimestampMs() -> UInt64 { - return UInt64(SnodeAPI.currentOffsetTimestampMs()) - } -} diff --git a/SessionSnodeKit/Models/OnionRequestAPIDestination.swift b/SessionSnodeKit/Types/OnionRequestAPIDestination.swift similarity index 85% rename from SessionSnodeKit/Models/OnionRequestAPIDestination.swift rename to SessionSnodeKit/Types/OnionRequestAPIDestination.swift index 8483ce347..235bb817e 100644 --- a/SessionSnodeKit/Models/OnionRequestAPIDestination.swift +++ b/SessionSnodeKit/Types/OnionRequestAPIDestination.swift @@ -2,7 +2,7 @@ import Foundation -public enum OnionRequestAPIDestination: CustomStringConvertible { +public enum OnionRequestAPIDestination: CustomStringConvertible, Codable { case snode(Snode) case server(host: String, target: String, x25519PublicKey: String, scheme: String?, port: UInt16?) diff --git a/SessionSnodeKit/Models/OnionRequestAPIError.swift b/SessionSnodeKit/Types/OnionRequestAPIError.swift similarity index 100% rename from SessionSnodeKit/Models/OnionRequestAPIError.swift rename to SessionSnodeKit/Types/OnionRequestAPIError.swift diff --git a/SessionSnodeKit/Models/OnionRequestAPIVersion.swift b/SessionSnodeKit/Types/OnionRequestAPIVersion.swift similarity index 100% rename from SessionSnodeKit/Models/OnionRequestAPIVersion.swift rename to SessionSnodeKit/Types/OnionRequestAPIVersion.swift diff --git a/SessionSnodeKit/Types/SnodeAPIEndpoint.swift b/SessionSnodeKit/Types/SnodeAPIEndpoint.swift new file mode 100644 index 000000000..ca988964e --- /dev/null +++ b/SessionSnodeKit/Types/SnodeAPIEndpoint.swift @@ -0,0 +1,33 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension SnodeAPI { + enum Endpoint: String { + case sendMessage = "store" + case getMessages = "retrieve" + case deleteMessages = "delete" + case deleteAll = "delete_all" + case deleteAllBefore = "delete_before" + case revokeSubkey = "revoke_subkey" + case expire = "expire" + case expireAll = "expire_all" + case batch = "batch" + case sequence = "sequence" + + case getInfo = "info" + case getSwarm = "get_snodes_for_pubkey" + + case jsonRPCCall = "json_rpc" + case oxenDaemonRPCCall = "oxend_request" + + // jsonRPCCall proxied calls + + case jsonGetNServiceNodes = "get_n_service_nodes" + + // oxenDaemonRPCCall proxied calls + + case daemonOnsResolve = "ons_resolve" + case daemonGetServiceNodes = "get_service_nodes" + } +} diff --git a/SessionSnodeKit/Models/SnodeAPIError.swift b/SessionSnodeKit/Types/SnodeAPIError.swift similarity index 87% rename from SessionSnodeKit/Models/SnodeAPIError.swift rename to SessionSnodeKit/Types/SnodeAPIError.swift index 60403b0ec..a7ec7050f 100644 --- a/SessionSnodeKit/Models/SnodeAPIError.swift +++ b/SessionSnodeKit/Types/SnodeAPIError.swift @@ -11,6 +11,8 @@ public enum SnodeAPIError: LocalizedError { case signingFailed case signatureVerificationFailed case invalidIP + case emptySnodePool + case responseFailedValidation // ONS case decryptionFailed @@ -27,6 +29,8 @@ public enum SnodeAPIError: LocalizedError { case .signingFailed: return "Couldn't sign message." case .signatureVerificationFailed: return "Failed to verify the signature." case .invalidIP: return "Invalid IP." + case .emptySnodePool: return "Service Node pool is empty." + case .responseFailedValidation: return "Response failed validation." // ONS case .decryptionFailed: return "Couldn't decrypt ONS name." diff --git a/SessionSnodeKit/Types/SnodeAPINamespace.swift b/SessionSnodeKit/Types/SnodeAPINamespace.swift new file mode 100644 index 000000000..73dd9182d --- /dev/null +++ b/SessionSnodeKit/Types/SnodeAPINamespace.swift @@ -0,0 +1,115 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension SnodeAPI { + enum Namespace: Int, Codable, Hashable { + case `default` = 0 + + case configUserProfile = 2 + case configContacts = 3 + case configConvoInfoVolatile = 4 + case configUserGroups = 5 + case configClosedGroupInfo = 11 + + case legacyClosedGroup = -10 + + case all = -9999990 + + // MARK: Variables + + var requiresReadAuthentication: Bool { + switch self { + // Legacy closed groups don't support authenticated retrieval + case .legacyClosedGroup: return false + default: return true + } + } + + var requiresWriteAuthentication: Bool { + switch self { + // Legacy closed groups don't support authenticated storage + case .legacyClosedGroup: return false + default: return true + } + } + + /// This flag indicates whether we should provide a `lastHash` when retrieving messages from the specified + /// namespace, when `true` we will only receive messages added since the provided `lastHash`, otherwise + /// we will retrieve **all** messages from the namespace + public var shouldFetchSinceLastHash: Bool { true } + + /// This flag indicates whether we should dedupe messages from the specified namespace, when `true` we will + /// store a `SnodeReceivedMessageInfo` record for the message and check for a matching record whenever + /// we receive a message from this namespace + /// + /// **Note:** An additional side-effect of this flag is that when we poll for messages from the specified namespace + /// we will always retrieve **all** messages from the namespace (instead of just new messages since the last one + /// we have seen) + public var shouldDedupeMessages: Bool { + switch self { + case .`default`, .legacyClosedGroup: return true + + case .configUserProfile, .configContacts, + .configConvoInfoVolatile, .configUserGroups, + .configClosedGroupInfo, .all: + return false + } + } + + var verificationString: String { + switch self { + case .`default`: return "" + case .all: return "all" + default: return "\(self.rawValue)" + } + } + + /// When performing a batch request we want to try to use the amount of data available in the response as effectively as possible + /// this priority allows us to split the response effectively between the number of namespaces we are requesting from where + /// namespaces with the same priority will be given the same response size divider, for example: + /// ``` + /// default = 1 + /// config1, config2 = 2 + /// config3, config4 = 3 + /// + /// Response data split: + /// _____________________________ + /// | | + /// | default | + /// |_____________________________| + /// | | | config3 | + /// | config1 | config2 | config4 | + /// |_________|_________|_________| + /// + var batchRequestSizePriority: Int64 { + switch self { + case .`default`, .legacyClosedGroup: return 10 + + case .configUserProfile, .configContacts, + .configConvoInfoVolatile, .configUserGroups, + .configClosedGroupInfo, .all: + return 1 + } + } + + static func maxSizeMap(for namespaces: [Namespace]) -> [Namespace: Int64] { + var lastSplit: Int64 = 1 + let namespacePriorityGroups: [Int64: [Namespace]] = namespaces + .grouped { $0.batchRequestSizePriority } + let lowestPriority: Int64 = (namespacePriorityGroups.keys.min() ?? 1) + + return namespacePriorityGroups + .map { $0 } + .sorted(by: { lhs, rhs -> Bool in lhs.key > rhs.key }) + .flatMap { priority, namespaces -> [(namespace: Namespace, maxSize: Int64)] in + lastSplit *= Int64(namespaces.count + (priority == lowestPriority ? 0 : 1)) + + return namespaces.map { ($0, lastSplit) } + } + .reduce(into: [:]) { result, next in + result[next.namespace] = -next.maxSize + } + } + } +} diff --git a/SessionSnodeKit/Types/ValidatableResponse.swift b/SessionSnodeKit/Types/ValidatableResponse.swift new file mode 100644 index 000000000..67e573a37 --- /dev/null +++ b/SessionSnodeKit/Types/ValidatableResponse.swift @@ -0,0 +1,86 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Sodium + +internal protocol ValidatableResponse { + associatedtype ValidationData + associatedtype ValidationResponse + + /// This valid controls the number of successful responses for a response to be considered "valid", a + /// positive number indicates an exact number of responses required whereas a negative number indicates + /// a dividing factor, eg. + /// 2 = Two nodes need to have returned success responses + /// -2 = 50% of the nodes need to have returned success responses + /// -4 = 25% of the nodes need to have returned success responses + static var requiredSuccessfulResponses: Int { get } + + static func validated( + map validResultMap: [String: ValidationResponse], + totalResponseCount: Int + ) throws -> [String: ValidationResponse] + + func validResultMap( + sodium: Sodium, + userX25519PublicKey: String, + validationData: ValidationData + ) throws -> [String: ValidationResponse] + + func validateResultMap(sodium: Sodium, userX25519PublicKey: String, validationData: ValidationData) throws +} + +// MARK: - Convenience + +internal extension ValidatableResponse { + func validateResultMap(sodium: Sodium, userX25519PublicKey: String, validationData: ValidationData) throws { + _ = try validResultMap( + sodium: sodium, + userX25519PublicKey: userX25519PublicKey, + validationData: validationData + ) + } + + static func validated( + map validResultMap: [String: ValidationResponse], + totalResponseCount: Int + ) throws -> [String: ValidationResponse] { + let numSuccessResponses: Int = validResultMap.count + let successPercentage: CGFloat = (CGFloat(numSuccessResponses) / CGFloat(totalResponseCount)) + + guard + ( // Positive value is an exact number comparison + Self.requiredSuccessfulResponses >= 0 && + numSuccessResponses >= Self.requiredSuccessfulResponses + ) || ( + // Negative value is a "divisor" for a percentage comparison + Self.requiredSuccessfulResponses < 0 && + successPercentage >= abs(1 / CGFloat(Self.requiredSuccessfulResponses)) + ) + else { throw SnodeAPIError.responseFailedValidation } + + return validResultMap + } +} + +internal extension ValidatableResponse where ValidationData == Void { + func validResultMap(sodium: Sodium, userX25519PublicKey: String) throws -> [String: ValidationResponse] { + return try validResultMap(sodium: sodium, userX25519PublicKey: userX25519PublicKey, validationData: ()) + } + + func validateResultMap(sodium: Sodium, userX25519PublicKey: String) throws { + _ = try validResultMap( + sodium: sodium, + userX25519PublicKey: userX25519PublicKey, + validationData: () + ) + } +} + +internal extension ValidatableResponse where ValidationResponse == Bool { + static func validated(map validResultMap: [String: Bool]) throws -> [String: Bool] { + return try validated( + map: validResultMap.filter { $0.value }, + totalResponseCount: validResultMap.count + ) + } +} diff --git a/SessionSnodeKit/Utilities/Promise+Hashing.swift b/SessionSnodeKit/Utilities/Promise+Hashing.swift deleted file mode 100644 index 47bb42c62..000000000 --- a/SessionSnodeKit/Utilities/Promise+Hashing.swift +++ /dev/null @@ -1,13 +0,0 @@ -import PromiseKit - -extension Promise : Hashable { - - public func hash(into hasher: inout Hasher) { - let reference = ObjectIdentifier(self) - hasher.combine(reference.hashValue) - } - - public static func == (lhs: Promise, rhs: Promise) -> Bool { - return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) - } -} diff --git a/SessionSnodeKit/Utilities/Promise+Threading.swift b/SessionSnodeKit/Utilities/Promise+Threading.swift deleted file mode 100644 index ce7d5ab7a..000000000 --- a/SessionSnodeKit/Utilities/Promise+Threading.swift +++ /dev/null @@ -1,91 +0,0 @@ -import PromiseKit - -public extension Thenable { - - @discardableResult - func then2(_ body: @escaping (T) throws -> U) -> Promise where U : Thenable { - return then(on: Threading.workQueue, body) - } - - @discardableResult - func map2(_ transform: @escaping (T) throws -> U) -> Promise { - return map(on: Threading.workQueue, transform) - } - - @discardableResult - func done2(_ body: @escaping (T) throws -> Void) -> Promise { - return done(on: Threading.workQueue, body) - } - - @discardableResult - func get2(_ body: @escaping (T) throws -> Void) -> Promise { - return get(on: Threading.workQueue, body) - } -} - -public extension Thenable where T: Sequence { - - @discardableResult - func mapValues2(_ transform: @escaping (T.Iterator.Element) throws -> U) -> Promise<[U]> { - return mapValues(on: Threading.workQueue, transform) - } -} - -public extension Guarantee { - - @discardableResult - func then2(_ body: @escaping (T) -> Guarantee) -> Guarantee { - return then(on: Threading.workQueue, body) - } - - @discardableResult - func map2(_ body: @escaping (T) -> U) -> Guarantee { - return map(on: Threading.workQueue, body) - } - - @discardableResult - func done2(_ body: @escaping (T) -> Void) -> Guarantee { - return done(on: Threading.workQueue, body) - } - - @discardableResult - func get2(_ body: @escaping (T) -> Void) -> Guarantee { - return get(on: Threading.workQueue, body) - } -} - -public extension CatchMixin { - - @discardableResult - func catch2(_ body: @escaping (Error) -> Void) -> PMKFinalizer { - return self.catch(on: Threading.workQueue, body) - } - - @discardableResult - func recover2(_ body: @escaping(Error) throws -> U) -> Promise where U.T == T { - return recover(on: Threading.workQueue, body) - } - - @discardableResult - func recover2(_ body: @escaping(Error) -> Guarantee) -> Guarantee { - return recover(on: Threading.workQueue, body) - } - - @discardableResult - func ensure2(_ body: @escaping () -> Void) -> Promise { - return ensure(on: Threading.workQueue, body) - } -} - -public extension CatchMixin where T == Void { - - @discardableResult - func recover2(_ body: @escaping(Error) -> Void) -> Guarantee { - return recover(on: Threading.workQueue, body) - } - - @discardableResult - func recover2(_ body: @escaping(Error) throws -> Void) -> Promise { - return recover(on: Threading.workQueue, body) - } -} diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index 3f23e35f2..0a9dfcf21 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -4,11 +4,13 @@ import Combine import GRDB import Quick import Nimble +import SessionUIKit +import SessionSnodeKit @testable import Session -class ThreadDisappearingMessagesViewModelSpec: QuickSpec { - typealias ParentType = SessionTableViewModel +class ThreadDisappearingMessagesSettingsViewModelSpec: QuickSpec { + typealias ParentType = SessionTableViewModel // MARK: - Spec @@ -16,19 +18,19 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { var mockStorage: Storage! var cancellables: [AnyCancellable] = [] var dependencies: Dependencies! - var viewModel: ThreadDisappearingMessagesViewModel! + var viewModel: ThreadDisappearingMessagesSettingsViewModel! - describe("a ThreadDisappearingMessagesViewModel") { + describe("a ThreadDisappearingMessagesSettingsViewModel") { // MARK: - Configuration beforeEach { mockStorage = Storage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations(), - SNSnodeKit.migrations(), - SNMessagingKit.migrations(), - SNUIKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self, + SNSnodeKit.self, + SNMessagingKit.self, + SNUIKit.self ] ) dependencies = Dependencies( @@ -41,17 +43,18 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { variant: .contact ).insert(db) } - viewModel = ThreadDisappearingMessagesViewModel( + viewModel = ThreadDisappearingMessagesSettingsViewModel( dependencies: dependencies, threadId: "TestId", + threadVariant: .contact, config: DisappearingMessagesConfiguration.defaultWith("TestId") ) cancellables.append( - viewModel.observableSettingsData - .receiveOnMain(immediately: true) + viewModel.observableTableData + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } + receiveValue: { viewModel.updateTableData($0.0) } ) ) } @@ -72,20 +75,21 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { } it("has the correct number of items") { - expect(viewModel.settingsData.count) + expect(viewModel.tableData.count) .to(equal(1)) - expect(viewModel.settingsData.first?.elements.count) + expect(viewModel.tableData.first?.elements.count) .to(equal(12)) } it("has the correct default state") { - expect(viewModel.settingsData.first?.elements.first) + expect(viewModel.tableData.first?.elements.first) .to( equal( SessionCell.Info( - id: ThreadDisappearingMessagesViewModel.Item( + id: ThreadDisappearingMessagesSettingsViewModel.Item( title: "DISAPPEARING_MESSAGES_OFF".localized() ), + position: .top, title: "DISAPPEARING_MESSAGES_OFF".localized(), rightAccessory: .radio( isSelected: { true } @@ -97,11 +101,12 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { let title: String = (DisappearingMessagesConfiguration.validDurationsSeconds.last? .formatted(format: .long)) .defaulting(to: "") - expect(viewModel.settingsData.first?.elements.last) + expect(viewModel.tableData.first?.elements.last) .to( equal( SessionCell.Info( - id: ThreadDisappearingMessagesViewModel.Item(title: title), + id: ThreadDisappearingMessagesSettingsViewModel.Item(title: title), + position: .bottom, title: title, rightAccessory: .radio( isSelected: { false } @@ -121,27 +126,29 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { mockStorage.write { db in _ = try config.saved(db) } - viewModel = ThreadDisappearingMessagesViewModel( + viewModel = ThreadDisappearingMessagesSettingsViewModel( dependencies: dependencies, threadId: "TestId", + threadVariant: .contact, config: config ) cancellables.append( - viewModel.observableSettingsData - .receiveOnMain(immediately: true) + viewModel.observableTableData + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } + receiveValue: { viewModel.updateTableData($0.0) } ) ) - expect(viewModel.settingsData.first?.elements.first) + expect(viewModel.tableData.first?.elements.first) .to( equal( SessionCell.Info( - id: ThreadDisappearingMessagesViewModel.Item( + id: ThreadDisappearingMessagesSettingsViewModel.Item( title: "DISAPPEARING_MESSAGES_OFF".localized() ), + position: .top, title: "DISAPPEARING_MESSAGES_OFF".localized(), rightAccessory: .radio( isSelected: { false } @@ -153,11 +160,12 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { let title: String = (DisappearingMessagesConfiguration.validDurationsSeconds.last? .formatted(format: .long)) .defaulting(to: "") - expect(viewModel.settingsData.first?.elements.last) + expect(viewModel.tableData.first?.elements.last) .to( equal( SessionCell.Info( - id: ThreadDisappearingMessagesViewModel.Item(title: title), + id: ThreadDisappearingMessagesSettingsViewModel.Item(title: title), + position: .bottom, title: title, rightAccessory: .radio( isSelected: { true } @@ -172,7 +180,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { cancellables.append( viewModel.rightNavItems - .receiveOnMain(immediately: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { navItems in items = navItems } @@ -188,14 +196,14 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { beforeEach { cancellables.append( viewModel.rightNavItems - .receiveOnMain(immediately: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { navItems in items = navItems } ) ) - viewModel.settingsData.first?.elements.last?.onTap?(nil) + viewModel.tableData.first?.elements.last?.onTap?() } it("shows the save button") { @@ -215,7 +223,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { cancellables.append( viewModel.dismissScreen - .receiveOnMain(immediately: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { _ in didDismissScreen = true } @@ -238,16 +246,9 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec { try DisappearingMessagesConfiguration.fetchOne(db, id: "TestId") } - expect(updatedConfig?.isEnabled) - .toEventually( - beTrue(), - timeout: .milliseconds(100) - ) + expect(updatedConfig?.isEnabled).to(beTrue()) expect(updatedConfig?.durationSeconds) - .toEventually( - equal(DisappearingMessagesConfiguration.validDurationsSeconds.last ?? -1), - timeout: .milliseconds(100) - ) + .to(equal(DisappearingMessagesConfiguration.validDurationsSeconds.last ?? -1)) } } } diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 57babeb54..60ed929db 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -4,6 +4,8 @@ import Combine import GRDB import Quick import Nimble +import SessionUIKit +import SessionSnodeKit @testable import Session @@ -15,7 +17,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { override func spec() { var mockStorage: Storage! var mockGeneralCache: MockGeneralCache! - var cancellables: [AnyCancellable] = [] + var disposables: [AnyCancellable] = [] var dependencies: Dependencies! var viewModel: ThreadSettingsViewModel! var didTriggerSearchCallbackTriggered: Bool = false @@ -26,16 +28,16 @@ class ThreadSettingsViewModelSpec: QuickSpec { beforeEach { mockStorage = SynchronousStorage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations(), - SNSnodeKit.migrations(), - SNMessagingKit.migrations(), - SNUIKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self, + SNSnodeKit.self, + SNMessagingKit.self, + SNUIKit.self ] ) mockGeneralCache = MockGeneralCache() dependencies = Dependencies( - generalCache: Atomic(mockGeneralCache), + generalCache: mockGeneralCache, storage: mockStorage, scheduler: .immediate ) @@ -53,12 +55,16 @@ class ThreadSettingsViewModelSpec: QuickSpec { try Profile( id: "05\(TestConstants.publicKey)", - name: "TestMe" + name: "TestMe", + lastNameUpdate: 0, + lastProfilePictureUpdate: 0 ).insert(db) try Profile( id: "TestId", - name: "TestUser" + name: "TestUser", + lastNameUpdate: 0, + lastProfilePictureUpdate: 0 ).insert(db) } viewModel = ThreadSettingsViewModel( @@ -69,21 +75,21 @@ class ThreadSettingsViewModelSpec: QuickSpec { didTriggerSearchCallbackTriggered = true } ) - cancellables.append( - viewModel.observableSettingsData - .receiveOnMain(immediately: true) + disposables.append( + viewModel.observableTableData + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } + receiveValue: { viewModel.updateTableData($0.0) } ) ) } afterEach { - cancellables.forEach { $0.cancel() } + disposables.forEach { $0.cancel() } mockStorage = nil - cancellables = [] + disposables = [] dependencies = nil viewModel = nil didTriggerSearchCallbackTriggered = false @@ -93,21 +99,21 @@ class ThreadSettingsViewModelSpec: QuickSpec { context("with any conversation type") { it("triggers the search callback when tapping search") { - viewModel.settingsData + viewModel.tableData .first(where: { $0.model == .content })? .elements .first(where: { $0.id == .searchConversation })? - .onTap?(nil) + .onTap?() expect(didTriggerSearchCallbackTriggered).to(beTrue()) } it("mutes a conversation") { - viewModel.settingsData + viewModel.tableData .first(where: { $0.model == .content })? .elements .first(where: { $0.id == .notificationMute })? - .onTap?(nil) + .onTap?() expect( mockStorage @@ -133,11 +139,11 @@ class ThreadSettingsViewModelSpec: QuickSpec { ) .toNot(beNil()) - viewModel.settingsData + viewModel.tableData .first(where: { $0.model == .content })? .elements .first(where: { $0.id == .notificationMute })? - .onTap?(nil) + .onTap?() expect( mockStorage @@ -167,12 +173,12 @@ class ThreadSettingsViewModelSpec: QuickSpec { didTriggerSearchCallbackTriggered = true } ) - cancellables.append( - viewModel.observableSettingsData - .receiveOnMain(immediately: true) + disposables.append( + viewModel.observableTableData + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } + receiveValue: { viewModel.updateTableData($0.0) } ) ) } @@ -198,7 +204,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { it("has no mute button") { expect( - viewModel.settingsData + viewModel.tableData .first(where: { $0.model == .content })? .elements .first(where: { $0.id == .notificationMute }) @@ -207,16 +213,9 @@ class ThreadSettingsViewModelSpec: QuickSpec { context("when entering edit mode") { beforeEach { + viewModel.navState.sinkAndStore(in: &disposables) viewModel.rightNavItems.firstValue()??.first?.action?() - - let leftAccessory: SessionCell.Accessory? = viewModel.settingsData.first? - .elements.first? - .leftAccessory - - switch leftAccessory { - case .threadInfo(_, _, _, _, let titleChanged): titleChanged?("TestNew") - default: break - } + viewModel.textChanged("TestNew", for: .nickname) } it("enters the editing state") { @@ -340,16 +339,9 @@ class ThreadSettingsViewModelSpec: QuickSpec { context("when entering edit mode") { beforeEach { + viewModel.navState.sinkAndStore(in: &disposables) viewModel.rightNavItems.firstValue()??.first?.action?() - - let leftAccessory: SessionCell.Accessory? = viewModel.settingsData.first? - .elements.first? - .leftAccessory - - switch leftAccessory { - case .threadInfo(_, _, _, _, let titleChanged): titleChanged?("TestUserNew") - default: break - } + viewModel.textChanged("TestUserNew", for: .nickname) } it("enters the editing state") { @@ -443,24 +435,24 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: "TestId", - variant: .closedGroup + variant: .legacyGroup ).insert(db) } viewModel = ThreadSettingsViewModel( dependencies: dependencies, threadId: "TestId", - threadVariant: .closedGroup, + threadVariant: .legacyGroup, didTriggerSearch: { didTriggerSearchCallbackTriggered = true } ) - cancellables.append( - viewModel.observableSettingsData - .receiveOnMain(immediately: true) + disposables.append( + viewModel.observableTableData + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } + receiveValue: { viewModel.updateTableData($0.0) } ) ) } @@ -485,24 +477,24 @@ class ThreadSettingsViewModelSpec: QuickSpec { try SessionThread( id: "TestId", - variant: .openGroup + variant: .community ).insert(db) } viewModel = ThreadSettingsViewModel( dependencies: dependencies, threadId: "TestId", - threadVariant: .openGroup, + threadVariant: .community, didTriggerSearch: { didTriggerSearchCallbackTriggered = true } ) - cancellables.append( - viewModel.observableSettingsData - .receiveOnMain(immediately: true) + disposables.append( + viewModel.observableTableData + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } + receiveValue: { viewModel.updateTableData($0.0) } ) ) } diff --git a/SessionTests/Settings/NotificationContentViewModelSpec.swift b/SessionTests/Settings/NotificationContentViewModelSpec.swift index f683a2a49..e6d1e5999 100644 --- a/SessionTests/Settings/NotificationContentViewModelSpec.swift +++ b/SessionTests/Settings/NotificationContentViewModelSpec.swift @@ -5,6 +5,9 @@ import GRDB import Quick import Nimble +import SessionUIKit +import SessionSnodeKit + @testable import Session class NotificationContentViewModelSpec: QuickSpec { @@ -22,19 +25,19 @@ class NotificationContentViewModelSpec: QuickSpec { beforeEach { mockStorage = Storage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations(), - SNSnodeKit.migrations(), - SNMessagingKit.migrations(), - SNUIKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self, + SNSnodeKit.self, + SNMessagingKit.self, + SNUIKit.self ] ) viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate) - dataChangeCancellable = viewModel.observableSettingsData - .receiveOnMain(immediately: true) + dataChangeCancellable = viewModel.observableTableData + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } + receiveValue: { viewModel.updateTableData($0.0) } ) } @@ -55,18 +58,19 @@ class NotificationContentViewModelSpec: QuickSpec { } it("has the correct number of items") { - expect(viewModel.settingsData.count) + expect(viewModel.tableData.count) .to(equal(1)) - expect(viewModel.settingsData.first?.elements.count) + expect(viewModel.tableData.first?.elements.count) .to(equal(3)) } it("has the correct default state") { - expect(viewModel.settingsData.first?.elements) + expect(viewModel.tableData.first?.elements) .to( equal([ SessionCell.Info( id: Preferences.NotificationPreviewType.nameAndPreview, + position: .top, title: "NOTIFICATIONS_STYLE_CONTENT_OPTION_NAME_AND_CONTENT".localized(), rightAccessory: .radio( isSelected: { true } @@ -74,6 +78,7 @@ class NotificationContentViewModelSpec: QuickSpec { ), SessionCell.Info( id: Preferences.NotificationPreviewType.nameNoPreview, + position: .middle, title: "NOTIFICATIONS_STYLE_CONTENT_OPTION_NAME_ONLY".localized(), rightAccessory: .radio( isSelected: { false } @@ -81,6 +86,7 @@ class NotificationContentViewModelSpec: QuickSpec { ), SessionCell.Info( id: Preferences.NotificationPreviewType.noNameNoPreview, + position: .bottom, title: "NOTIFICATIONS_STYLE_CONTENT_OPTION_NO_NAME_OR_CONTENT".localized(), rightAccessory: .radio( isSelected: { false } @@ -95,18 +101,19 @@ class NotificationContentViewModelSpec: QuickSpec { db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType.nameNoPreview } viewModel = NotificationContentViewModel(storage: mockStorage, scheduling: .immediate) - dataChangeCancellable = viewModel.observableSettingsData - .receiveOnMain(immediately: true) + dataChangeCancellable = viewModel.observableTableData + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, - receiveValue: { viewModel.updateSettings($0) } + receiveValue: { viewModel.updateTableData($0.0) } ) - expect(viewModel.settingsData.first?.elements) + expect(viewModel.tableData.first?.elements) .to( equal([ SessionCell.Info( id: Preferences.NotificationPreviewType.nameAndPreview, + position: .top, title: "NOTIFICATIONS_STYLE_CONTENT_OPTION_NAME_AND_CONTENT".localized(), rightAccessory: .radio( isSelected: { false } @@ -114,6 +121,7 @@ class NotificationContentViewModelSpec: QuickSpec { ), SessionCell.Info( id: Preferences.NotificationPreviewType.nameNoPreview, + position: .middle, title: "NOTIFICATIONS_STYLE_CONTENT_OPTION_NAME_ONLY".localized(), rightAccessory: .radio( isSelected: { true } @@ -121,6 +129,7 @@ class NotificationContentViewModelSpec: QuickSpec { ), SessionCell.Info( id: Preferences.NotificationPreviewType.noNameNoPreview, + position: .bottom, title: "NOTIFICATIONS_STYLE_CONTENT_OPTION_NO_NAME_OR_CONTENT".localized(), rightAccessory: .radio( isSelected: { false } @@ -132,7 +141,7 @@ class NotificationContentViewModelSpec: QuickSpec { context("when tapping an item") { it("updates the saved preference") { - viewModel.settingsData.first?.elements.last?.onTap?(nil) + viewModel.tableData.first?.elements.last?.onTap?() expect(mockStorage[.preferencesNotificationPreviewType]) .to(equal(Preferences.NotificationPreviewType.noNameNoPreview)) @@ -142,12 +151,12 @@ class NotificationContentViewModelSpec: QuickSpec { var didDismissScreen: Bool = false dismissCancellable = viewModel.dismissScreen - .receiveOnMain(immediately: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { _ in didDismissScreen = true } ) - viewModel.settingsData.first?.elements.last?.onTap?(nil) + viewModel.tableData.first?.elements.last?.onTap?() expect(didDismissScreen).to(beTrue()) } diff --git a/SessionUIKit/Components/ConfirmationModal.swift b/SessionUIKit/Components/ConfirmationModal.swift index 93d1be709..a7f4088d7 100644 --- a/SessionUIKit/Components/ConfirmationModal.swift +++ b/SessionUIKit/Components/ConfirmationModal.swift @@ -4,16 +4,38 @@ import UIKit import SessionUtilitiesKit // FIXME: Refactor as part of the Groups Rebuild -public class ConfirmationModal: Modal { - private static let imageSize: CGFloat = 80 +public class ConfirmationModal: Modal, UITextFieldDelegate { private static let closeSize: CGFloat = 24 private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil private var internalOnCancel: ((ConfirmationModal) -> ())? = nil private var internalOnBodyTap: (() -> ())? = nil + private var internalOnTextChanged: ((String) -> ())? = nil // MARK: - Components + private lazy var contentTapGestureRecognizer: UITapGestureRecognizer = { + let result: UITapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(contentViewTapped) + ) + contentView.addGestureRecognizer(result) + result.isEnabled = false + + return result + }() + + private lazy var imageViewTapGestureRecognizer: UITapGestureRecognizer = { + let result: UITapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(imageViewTapped) + ) + imageViewContainer.addGestureRecognizer(result) + result.isEnabled = false + + return result + }() + private lazy var titleLabel: UILabel = { let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.mediumFontSize) @@ -37,6 +59,26 @@ public class ConfirmationModal: Modal { return result }() + private lazy var textFieldContainer: UIView = { + let result: UIView = UIView() + result.themeBorderColor = .borderSeparator + result.layer.cornerRadius = 11 + result.layer.borderWidth = 1 + result.isHidden = true + result.set(.height, to: 40) + + return result + }() + + private lazy var textField: UITextField = { + let result: UITextField = UITextField() + result.font = .systemFont(ofSize: Values.smallFontSize) + result.themeTextColor = .textPrimary + result.delegate = self + + return result + }() + private lazy var imageViewContainer: UIView = { let result: UIView = UIView() result.isHidden = true @@ -44,15 +86,7 @@ public class ConfirmationModal: Modal { return result }() - private lazy var imageView: UIImageView = { - let result: UIImageView = UIImageView() - result.clipsToBounds = true - result.contentMode = .scaleAspectFill - result.set(.width, to: ConfirmationModal.imageSize) - result.set(.height, to: ConfirmationModal.imageSize) - - return result - }() + private lazy var profileView: ProfilePictureView = ProfilePictureView(size: .hero) private lazy var confirmButton: UIButton = { let result: UIButton = Modal.createButton( @@ -73,7 +107,7 @@ public class ConfirmationModal: Modal { }() private lazy var contentStackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, imageViewContainer ]) + let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, textFieldContainer, imageViewContainer ]) result.axis = .vertical result.spacing = Values.smallSpacing result.isLayoutMarginsRelativeArrangement = true @@ -84,12 +118,6 @@ public class ConfirmationModal: Modal { right: Values.largeSpacing ) - let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer( - target: self, - action: #selector(bodyTapped) - ) - result.addGestureRecognizer(gestureRecogniser) - return result }() @@ -115,6 +143,9 @@ public class ConfirmationModal: Modal { bottom: 6, right: 6 ) + result.isAccessibilityElement = true + result.accessibilityIdentifier = "Close button" + result.accessibilityLabel = "Close button" result.set(.width, to: ConfirmationModal.closeSize) result.set(.height, to: ConfirmationModal.closeSize) result.addTarget(self, action: #selector(close), for: .touchUpInside) @@ -138,13 +169,22 @@ public class ConfirmationModal: Modal { } public override func populateContentView() { + let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(contentViewTapped) + ) + contentView.addGestureRecognizer(gestureRecogniser) + contentView.addSubview(mainStackView) contentView.addSubview(closeButton) - imageViewContainer.addSubview(imageView) - imageView.center(.horizontal, in: imageViewContainer) - imageView.pin(.top, to: .top, of: imageViewContainer, withInset: 15) - imageView.pin(.bottom, to: .bottom, of: imageViewContainer, withInset: -15) + textFieldContainer.addSubview(textField) + textField.pin(to: textFieldContainer, withInset: 12) + + imageViewContainer.addSubview(profileView) + profileView.center(.horizontal, in: imageViewContainer) + profileView.pin(.top, to: .top, of: imageViewContainer) + profileView.pin(.bottom, to: .bottom, of: imageViewContainer) mainStackView.pin(to: contentView) closeButton.pin(.top, to: .top, of: contentView, withInset: 8) @@ -155,6 +195,7 @@ public class ConfirmationModal: Modal { public func updateContent(with info: Info) { internalOnBodyTap = nil + internalOnTextChanged = nil internalOnConfirm = { modal in if info.dismissOnConfirm { modal.close() @@ -167,6 +208,8 @@ public class ConfirmationModal: Modal { info.onCancel?(modal) } + contentTapGestureRecognizer.isEnabled = true + imageViewTapGestureRecognizer.isEnabled = false // Set the content based on the provided info titleLabel.text = info.title @@ -185,27 +228,43 @@ public class ConfirmationModal: Modal { explanationLabel.attributedText = attributedText explanationLabel.isHidden = false - case .image(let placeholder, let value, let style, let onClick): + case .input(let explanation, let placeholder, let value, let clearButton, let onTextChanged): + explanationLabel.attributedText = explanation + explanationLabel.isHidden = (explanation == nil) + textField.placeholder = placeholder + textField.text = (value ?? "") + textField.clearButtonMode = (clearButton ? .always : .never) + textFieldContainer.isHidden = false + internalOnTextChanged = onTextChanged + + case .image(let placeholder, let value, let icon, let style, let accessibility, let onClick): + imageViewContainer.isAccessibilityElement = (accessibility != nil) + imageViewContainer.accessibilityIdentifier = accessibility?.identifier + imageViewContainer.accessibilityLabel = accessibility?.label mainStackView.spacing = 0 - imageView.image = (value ?? placeholder) - imageView.layer.cornerRadius = (style == .circular ? - (ConfirmationModal.imageSize / 2) : - 0 - ) imageViewContainer.isHidden = false + profileView.clipsToBounds = (style == .circular) + profileView.update( + ProfilePictureView.Info( + imageData: (value ?? placeholder), + icon: icon + ) + ) internalOnBodyTap = onClick + contentTapGestureRecognizer.isEnabled = false + imageViewTapGestureRecognizer.isEnabled = true } - confirmButton.accessibilityLabel = info.confirmAccessibilityLabel - confirmButton.accessibilityIdentifier = info.confirmAccessibilityLabel + confirmButton.accessibilityLabel = info.confirmAccessibility?.label + confirmButton.accessibilityIdentifier = info.confirmAccessibility?.identifier confirmButton.isAccessibilityElement = true confirmButton.setTitle(info.confirmTitle, for: .normal) confirmButton.setThemeTitleColor(info.confirmStyle, for: .normal) confirmButton.setThemeTitleColor(.disabled, for: .disabled) confirmButton.isHidden = (info.confirmTitle == nil) confirmButton.isEnabled = info.confirmEnabled - cancelButton.accessibilityLabel = info.cancelAccessibilityLabel - cancelButton.accessibilityIdentifier = info.cancelAccessibilityLabel + cancelButton.accessibilityLabel = info.cancelAccessibility?.label + cancelButton.accessibilityIdentifier = info.cancelAccessibility?.identifier cancelButton.isAccessibilityElement = true cancelButton.setTitle(info.cancelTitle, for: .normal) cancelButton.setThemeTitleColor(info.cancelStyle, for: .normal) @@ -213,13 +272,43 @@ public class ConfirmationModal: Modal { cancelButton.isEnabled = info.cancelEnabled closeButton.isHidden = !info.hasCloseButton - contentView.accessibilityLabel = info.accessibilityLabel - contentView.accessibilityIdentifier = info.accessibilityIdentifier + contentView.accessibilityLabel = info.accessibility?.label + contentView.accessibilityIdentifier = info.accessibility?.identifier + } + + // MARK: - UITextFieldDelegate + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + public func textFieldShouldClear(_ textField: UITextField) -> Bool { + internalOnTextChanged?("") + return true + } + + public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let text: String = textField.text, let textRange: Range = Range(range, in: text) { + let updatedText = text.replacingCharacters(in: textRange, with: string) + + internalOnTextChanged?(updatedText) + } + + return true } // MARK: - Interaction - @objc private func bodyTapped() { + @objc private func contentViewTapped() { + if textField.isFirstResponder { + textField.resignFirstResponder() + } + + internalOnBodyTap?() + } + + @objc private func imageViewTapped() { internalOnBodyTap?() } @@ -238,15 +327,14 @@ public extension ConfirmationModal { struct Info: Equatable, Hashable { let title: String let body: Body - let accessibilityLabel: String? - let accessibilityIdentifier: String? + let accessibility: Accessibility? public let showCondition: ShowCondition let confirmTitle: String? - let confirmAccessibilityLabel: String? + let confirmAccessibility: Accessibility? let confirmStyle: ThemeValue let confirmEnabled: Bool let cancelTitle: String - let cancelAccessibilityLabel: String? + let cancelAccessibility: Accessibility? let cancelStyle: ThemeValue let cancelEnabled: Bool let hasCloseButton: Bool @@ -261,15 +349,16 @@ public extension ConfirmationModal { public init( title: String, body: Body = .none, - accessibilityLabel: String? = nil, - accessibilityId: String? = nil, + accessibility: Accessibility? = nil, showCondition: ShowCondition = .none, confirmTitle: String? = nil, - confirmAccessibilityLabel: String? = nil, + confirmAccessibility: Accessibility? = nil, confirmStyle: ThemeValue = .alert_text, confirmEnabled: Bool = true, cancelTitle: String = "TXT_CANCEL_TITLE".localized(), - cancelAccessibilityLabel: String? = nil, + cancelAccessibility: Accessibility? = Accessibility( + identifier: "Cancel" + ), cancelStyle: ThemeValue = .danger, cancelEnabled: Bool = true, hasCloseButton: Bool = false, @@ -281,15 +370,14 @@ public extension ConfirmationModal { ) { self.title = title self.body = body - self.accessibilityLabel = accessibilityLabel - self.accessibilityIdentifier = accessibilityId + self.accessibility = accessibility self.showCondition = showCondition self.confirmTitle = confirmTitle - self.confirmAccessibilityLabel = confirmAccessibilityLabel + self.confirmAccessibility = confirmAccessibility self.confirmStyle = confirmStyle self.confirmEnabled = confirmEnabled self.cancelTitle = cancelTitle - self.cancelAccessibilityLabel = cancelAccessibilityLabel + self.cancelAccessibility = cancelAccessibility self.cancelStyle = cancelStyle self.cancelEnabled = cancelEnabled self.hasCloseButton = hasCloseButton @@ -313,14 +401,14 @@ public extension ConfirmationModal { return Info( title: self.title, body: (body ?? self.body), - accessibilityLabel: self.accessibilityLabel, + accessibility: self.accessibility, showCondition: self.showCondition, confirmTitle: self.confirmTitle, - confirmAccessibilityLabel: self.confirmAccessibilityLabel, + confirmAccessibility: self.confirmAccessibility, confirmStyle: self.confirmStyle, confirmEnabled: (confirmEnabled ?? self.confirmEnabled), cancelTitle: self.cancelTitle, - cancelAccessibilityLabel: self.cancelAccessibilityLabel, + cancelAccessibility: self.cancelAccessibility, cancelStyle: self.cancelStyle, cancelEnabled: (cancelEnabled ?? self.cancelEnabled), hasCloseButton: self.hasCloseButton, @@ -338,14 +426,14 @@ public extension ConfirmationModal { return ( lhs.title == rhs.title && lhs.body == rhs.body && - lhs.accessibilityLabel == rhs.accessibilityLabel && + lhs.accessibility == rhs.accessibility && lhs.showCondition == rhs.showCondition && lhs.confirmTitle == rhs.confirmTitle && - lhs.confirmAccessibilityLabel == rhs.confirmAccessibilityLabel && + lhs.confirmAccessibility == rhs.confirmAccessibility && lhs.confirmStyle == rhs.confirmStyle && lhs.confirmEnabled == rhs.confirmEnabled && lhs.cancelTitle == rhs.cancelTitle && - lhs.cancelAccessibilityLabel == rhs.cancelAccessibilityLabel && + lhs.cancelAccessibility == rhs.cancelAccessibility && lhs.cancelStyle == rhs.cancelStyle && lhs.cancelEnabled == rhs.cancelEnabled && lhs.hasCloseButton == rhs.hasCloseButton && @@ -357,14 +445,14 @@ public extension ConfirmationModal { public func hash(into hasher: inout Hasher) { title.hash(into: &hasher) body.hash(into: &hasher) - accessibilityLabel.hash(into: &hasher) + accessibility.hash(into: &hasher) showCondition.hash(into: &hasher) confirmTitle.hash(into: &hasher) - confirmAccessibilityLabel.hash(into: &hasher) + confirmAccessibility.hash(into: &hasher) confirmStyle.hash(into: &hasher) confirmEnabled.hash(into: &hasher) cancelTitle.hash(into: &hasher) - cancelAccessibilityLabel.hash(into: &hasher) + cancelAccessibility.hash(into: &hasher) cancelStyle.hash(into: &hasher) cancelEnabled.hash(into: &hasher) hasCloseButton.hash(into: &hasher) @@ -402,13 +490,21 @@ public extension ConfirmationModal.Info { case none case text(String) case attributedText(NSAttributedString) - // FIXME: Implement these - // case input(placeholder: String, value: String?) + case input( + explanation: NSAttributedString?, + placeholder: String, + initialValue: String?, + clearButton: Bool, + onChange: (String) -> () + ) + // FIXME: Implement this // case radio(explanation: NSAttributedString?, options: [(title: String, selected: Bool)]) case image( - placeholder: UIImage?, - value: UIImage?, + placeholderData: Data?, + valueData: Data?, + icon: ProfilePictureView.ProfileIcon = .none, style: ImageStyle, + accessibility: Accessibility?, onClick: (() -> ()) ) @@ -418,25 +514,28 @@ public extension ConfirmationModal.Info { case (.text(let lhsText), .text(let rhsText)): return (lhsText == rhsText) case (.attributedText(let lhsText), .attributedText(let rhsText)): return (lhsText == rhsText) - // FIXME: Implement these - //case (.input(let lhsPlaceholder, let lhsValue), .input(let rhsPlaceholder, let rhsValue)): - // return ( - // lhsPlaceholder == rhsPlaceholder && - // lhsValue == rhsValue && - // ) + case (.input(let lhsExplanation, let lhsPlaceholder, let lhsInitialValue, let lhsClearButton, _), .input(let rhsExplanation, let rhsPlaceholder, let rhsInitialValue, let rhsClearButton, _)): + return ( + lhsExplanation == rhsExplanation && + lhsPlaceholder == rhsPlaceholder && + lhsInitialValue == rhsInitialValue && + lhsClearButton == rhsClearButton + ) - // FIXME: Implement these + // FIXME: Implement this //case (.radio(let lhsExplanation, let lhsOptions), .radio(let rhsExplanation, let rhsOptions)): // return ( // lhsExplanation == rhsExplanation && // lhsOptions.map { "\($0.0)-\($0.1)" } == rhsValue.map { "\($0.0)-\($0.1)" } // ) - case (.image(let lhsPlaceholder, let lhsValue, let lhsStyle, _), .image(let rhsPlaceholder, let rhsValue, let rhsStyle, _)): + case (.image(let lhsPlaceholder, let lhsValue, let lhsIcon, let lhsStyle, let lhsAccessibility, _), .image(let rhsPlaceholder, let rhsValue, let rhsIcon, let rhsStyle, let rhsAccessibility, _)): return ( lhsPlaceholder == rhsPlaceholder && lhsValue == rhsValue && - lhsStyle == rhsStyle + lhsIcon == rhsIcon && + lhsStyle == rhsStyle && + lhsAccessibility == rhsAccessibility ) default: return false @@ -448,11 +547,19 @@ public extension ConfirmationModal.Info { case .none: break case .text(let text): text.hash(into: &hasher) case .attributedText(let text): text.hash(into: &hasher) + + case .input(let explanation, let placeholder, let initialValue, let clearButton, _): + explanation.hash(into: &hasher) + placeholder.hash(into: &hasher) + initialValue.hash(into: &hasher) + clearButton.hash(into: &hasher) - case .image(let placeholder, let value, let style, _): + case .image(let placeholder, let value, let icon, let style, let accessibility, _): placeholder.hash(into: &hasher) value.hash(into: &hasher) + icon.hash(into: &hasher) style.hash(into: &hasher) + accessibility.hash(into: &hasher) } } } diff --git a/SessionUIKit/Components/HighlightMentionBackgroundView.swift b/SessionUIKit/Components/HighlightMentionBackgroundView.swift index 450636c53..289787c21 100644 --- a/SessionUIKit/Components/HighlightMentionBackgroundView.swift +++ b/SessionUIKit/Components/HighlightMentionBackgroundView.swift @@ -92,7 +92,7 @@ public class HighlightMentionBackgroundView: UIView { var ascent: CGFloat = 0 var descent: CGFloat = 0 var leading: CGFloat = 0 - let lineWidth = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading)) + _ = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading)) for run in runs { let attributes: NSDictionary = CTRunGetAttributes(run) diff --git a/SignalUtilitiesKit/Profile Pictures/PlaceholderIcon.swift b/SessionUIKit/Components/PlaceholderIcon.swift similarity index 52% rename from SignalUtilitiesKit/Profile Pictures/PlaceholderIcon.swift rename to SessionUIKit/Components/PlaceholderIcon.swift index 33246ebce..c823d7e64 100644 --- a/SignalUtilitiesKit/Profile Pictures/PlaceholderIcon.swift +++ b/SessionUIKit/Components/PlaceholderIcon.swift @@ -1,15 +1,24 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import CryptoSwift -import SessionUIKit +import CryptoKit +import SessionUtilitiesKit public class PlaceholderIcon { + private static let placeholderCache: Atomic> = { + let result = NSCache() + result.countLimit = 50 + + return Atomic(result) + }() + private let seed: Int // Colour palette private var colors: [UIColor] = Theme.PrimaryColor.allCases.map { $0.color } + // MARK: - Initialization + init(seed: Int, colors: [UIColor]? = nil) { self.seed = seed if let colors = colors { self.colors = colors } @@ -18,10 +27,13 @@ public class PlaceholderIcon { convenience init(seed: String, colors: [UIColor]? = nil) { // Ensure we have a correct hash var hash = seed - if (hash.matches("^[0-9A-Fa-f]+$") && hash.count >= 12) { hash = seed.sha512() } + + if (hash.matches("^[0-9A-Fa-f]+$") && hash.count >= 12) { + hash = SHA512.hash(data: Data(seed.bytes)).hexString + } guard let number = Int(hash.substring(to: 12), radix: 16) else { - owsFailDebug("Failed to generate number from seed string: \(seed).") + SNLog("Failed to generate number from seed string: \(seed).") self.init(seed: 0, colors: colors) return } @@ -29,7 +41,53 @@ public class PlaceholderIcon { self.init(seed: number, colors: colors) } - public func generateLayer(with diameter: CGFloat, text: String) -> CALayer { + // MARK: - Convenience + + public static func generate(seed: String, text: String, size: CGFloat) -> UIImage { + let icon = PlaceholderIcon(seed: seed) + + 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 ? + initials.substring(to: 2).uppercased() : + content.substring(to: 2).uppercased() + ) + ) + + let rect = CGRect(origin: CGPoint.zero, size: layer.frame.size) + let renderer = UIGraphicsImageRenderer(size: rect.size) + let result = renderer.image { layer.render(in: $0.cgContext) } + + placeholderCache.mutate { $0.setObject(result, forKey: cacheKey as NSString) } + + return result + } + + // MARK: - Internal + + private func generateLayer(with diameter: CGFloat, text: String) -> CALayer { let color: UIColor = self.colors[seed % self.colors.count] let base: CALayer = getTextLayer(with: diameter, color: color, text: text) base.masksToBounds = true diff --git a/SessionUIKit/Components/ProfilePictureView.swift b/SessionUIKit/Components/ProfilePictureView.swift new file mode 100644 index 000000000..b05e69606 --- /dev/null +++ b/SessionUIKit/Components/ProfilePictureView.swift @@ -0,0 +1,552 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import YYImage + +public final class ProfilePictureView: UIView { + public struct Info { + let imageData: Data? + let renderingMode: UIImage.RenderingMode + let themeTintColor: ThemeValue? + let inset: UIEdgeInsets + let icon: ProfileIcon + let backgroundColor: ThemeValue? + let forcedBackgroundColor: ForcedThemeValue? + + public init( + imageData: Data?, + renderingMode: UIImage.RenderingMode = .automatic, + themeTintColor: ThemeValue? = nil, + inset: UIEdgeInsets = .zero, + icon: ProfileIcon = .none, + backgroundColor: ThemeValue? = nil, + forcedBackgroundColor: ForcedThemeValue? = nil + ) { + self.imageData = imageData + self.renderingMode = renderingMode + self.themeTintColor = themeTintColor + self.inset = inset + self.icon = icon + self.backgroundColor = backgroundColor + self.forcedBackgroundColor = forcedBackgroundColor + } + } + + public enum Size { + case navigation + case message + case list + case hero + + public var viewSize: CGFloat { + switch self { + case .navigation, .message: return 26 + case .list: return 46 + case .hero: return 110 + } + } + + public var imageSize: CGFloat { + switch self { + case .navigation, .message: return 26 + case .list: return 46 + case .hero: return 80 + } + } + + public var multiImageSize: CGFloat { + switch self { + case .navigation, .message: return 18 // Shouldn't be used + case .list: return 32 + case .hero: return 80 + } + } + + var iconSize: CGFloat { + switch self { + case .navigation, .message: return 10 // Intentionally not a multiple of 4 + case .list: return 16 + case .hero: return 24 + } + } + } + + public enum ProfileIcon: Equatable, Hashable { + case none + case crown + case rightPlus + + func iconVerticalInset(for size: Size) -> CGFloat { + switch (self, size) { + case (.crown, .navigation), (.crown, .message): return 1 + case (.crown, .list): return 3 + case (.crown, .hero): return 5 + + case (.rightPlus, _): return 3 + default: return 0 + } + } + } + + public var size: Size { + didSet { + widthConstraint.constant = (customWidth ?? size.viewSize) + heightConstraint.constant = size.viewSize + profileIconBackgroundWidthConstraint.constant = size.iconSize + profileIconBackgroundHeightConstraint.constant = size.iconSize + additionalProfileIconBackgroundWidthConstraint.constant = size.iconSize + additionalProfileIconBackgroundHeightConstraint.constant = size.iconSize + + profileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) + additionalProfileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) + } + } + public var customWidth: CGFloat? { + didSet { + self.widthConstraint.constant = (customWidth ?? self.size.viewSize) + } + } + override public var clipsToBounds: Bool { + didSet { + imageContainerView.clipsToBounds = clipsToBounds + additionalImageContainerView.clipsToBounds = clipsToBounds + + imageContainerView.layer.cornerRadius = (clipsToBounds ? + (additionalImageContainerView.isHidden ? (size.imageSize / 2) : (size.multiImageSize / 2)) : + 0 + ) + imageContainerView.layer.cornerRadius = (clipsToBounds ? (size.multiImageSize / 2) : 0) + } + } + public override var isHidden: Bool { + didSet { + widthConstraint.constant = (isHidden ? 0 : size.viewSize) + heightConstraint.constant = (isHidden ? 0 : size.viewSize) + } + } + + // MARK: - Constraints + + private var widthConstraint: NSLayoutConstraint! + private var heightConstraint: NSLayoutConstraint! + private var imageViewTopConstraint: NSLayoutConstraint! + private var imageViewLeadingConstraint: NSLayoutConstraint! + private var imageViewCenterXConstraint: NSLayoutConstraint! + private var imageViewCenterYConstraint: NSLayoutConstraint! + private var imageViewWidthConstraint: NSLayoutConstraint! + private var imageViewHeightConstraint: NSLayoutConstraint! + private var additionalImageViewWidthConstraint: NSLayoutConstraint! + private var additionalImageViewHeightConstraint: NSLayoutConstraint! + private var profileIconTopConstraint: NSLayoutConstraint! + private var profileIconBottomConstraint: NSLayoutConstraint! + private var profileIconBackgroundLeftAlignConstraint: NSLayoutConstraint! + private var profileIconBackgroundRightAlignConstraint: NSLayoutConstraint! + private var profileIconBackgroundWidthConstraint: NSLayoutConstraint! + private var profileIconBackgroundHeightConstraint: NSLayoutConstraint! + private var additionalProfileIconTopConstraint: NSLayoutConstraint! + private var additionalProfileIconBottomConstraint: NSLayoutConstraint! + private var additionalProfileIconBackgroundLeftAlignConstraint: NSLayoutConstraint! + private var additionalProfileIconBackgroundRightAlignConstraint: NSLayoutConstraint! + private var additionalProfileIconBackgroundWidthConstraint: NSLayoutConstraint! + private var additionalProfileIconBackgroundHeightConstraint: NSLayoutConstraint! + private lazy var imageEdgeConstraints: [NSLayoutConstraint] = [ // MUST be in 'top, left, bottom, right' order + imageView.pin(.top, to: .top, of: imageContainerView, withInset: 0), + imageView.pin(.left, to: .left, of: imageContainerView, withInset: 0), + imageView.pin(.bottom, to: .bottom, of: imageContainerView, withInset: 0), + imageView.pin(.right, to: .right, of: imageContainerView, withInset: 0), + animatedImageView.pin(.top, to: .top, of: imageContainerView, withInset: 0), + animatedImageView.pin(.left, to: .left, of: imageContainerView, withInset: 0), + animatedImageView.pin(.bottom, to: .bottom, of: imageContainerView, withInset: 0), + animatedImageView.pin(.right, to: .right, of: imageContainerView, withInset: 0) + ] + private lazy var additionalImageEdgeConstraints: [NSLayoutConstraint] = [ // MUST be in 'top, left, bottom, right' order + additionalImageView.pin(.top, to: .top, of: additionalImageContainerView, withInset: 0), + additionalImageView.pin(.left, to: .left, of: additionalImageContainerView, withInset: 0), + additionalImageView.pin(.bottom, to: .bottom, of: additionalImageContainerView, withInset: 0), + additionalImageView.pin(.right, to: .right, of: additionalImageContainerView, withInset: 0), + additionalAnimatedImageView.pin(.top, to: .top, of: additionalImageContainerView, withInset: 0), + additionalAnimatedImageView.pin(.left, to: .left, of: additionalImageContainerView, withInset: 0), + additionalAnimatedImageView.pin(.bottom, to: .bottom, of: additionalImageContainerView, withInset: 0), + additionalAnimatedImageView.pin(.right, to: .right, of: additionalImageContainerView, withInset: 0) + ] + + // MARK: - Components + + private lazy var imageContainerView: UIView = { + let result: UIView = UIView() + result.translatesAutoresizingMaskIntoConstraints = false + result.clipsToBounds = true + result.themeBackgroundColor = .backgroundSecondary + + return result + }() + + private lazy var imageView: UIImageView = { + let result: UIImageView = UIImageView() + result.translatesAutoresizingMaskIntoConstraints = false + result.contentMode = .scaleAspectFill + result.isHidden = true + + return result + }() + + private lazy var animatedImageView: YYAnimatedImageView = { + let result: YYAnimatedImageView = YYAnimatedImageView() + result.translatesAutoresizingMaskIntoConstraints = false + result.contentMode = .scaleAspectFill + result.isHidden = true + + return result + }() + + private lazy var additionalImageContainerView: UIView = { + let result: UIView = UIView() + result.translatesAutoresizingMaskIntoConstraints = false + result.clipsToBounds = true + result.themeBackgroundColor = .primary + result.themeBorderColor = .backgroundPrimary + result.layer.borderWidth = 1 + result.isHidden = true + + return result + }() + + private lazy var additionalImageView: UIImageView = { + let result: UIImageView = UIImageView() + result.translatesAutoresizingMaskIntoConstraints = false + result.contentMode = .scaleAspectFill + result.themeTintColor = .textPrimary + result.isHidden = true + + return result + }() + + private lazy var additionalAnimatedImageView: YYAnimatedImageView = { + let result: YYAnimatedImageView = YYAnimatedImageView() + result.translatesAutoresizingMaskIntoConstraints = false + result.contentMode = .scaleAspectFill + result.isHidden = true + + return result + }() + + private lazy var profileIconBackgroundView: UIView = { + let result: UIView = UIView() + result.isHidden = true + + return result + }() + + private lazy var profileIconImageView: UIImageView = { + let result: UIImageView = UIImageView() + result.contentMode = .scaleAspectFit + + return result + }() + + private lazy var additionalProfileIconBackgroundView: UIView = { + let result: UIView = UIView() + result.isHidden = true + + return result + }() + + private lazy var additionalProfileIconImageView: UIImageView = { + let result: UIImageView = UIImageView() + result.contentMode = .scaleAspectFit + + return result + }() + + // MARK: - Lifecycle + + public init(size: Size) { + self.size = size + + super.init(frame: CGRect(x: 0, y: 0, width: size.viewSize, height: size.viewSize)) + + clipsToBounds = true + setUpViewHierarchy() + } + + public required init?(coder: NSCoder) { + preconditionFailure("Use init(size:) instead.") + } + + private func setUpViewHierarchy() { + addSubview(imageContainerView) + addSubview(profileIconBackgroundView) + addSubview(additionalImageContainerView) + addSubview(additionalProfileIconBackgroundView) + + profileIconBackgroundView.addSubview(profileIconImageView) + additionalProfileIconBackgroundView.addSubview(additionalProfileIconImageView) + + widthConstraint = self.set(.width, to: self.size.viewSize) + heightConstraint = self.set(.height, to: self.size.viewSize) + + imageViewTopConstraint = imageContainerView.pin(.top, to: .top, of: self) + imageViewLeadingConstraint = imageContainerView.pin(.leading, to: .leading, of: self) + imageViewCenterXConstraint = imageContainerView.center(.horizontal, in: self) + imageViewCenterXConstraint.isActive = false + imageViewCenterYConstraint = imageContainerView.center(.vertical, in: self) + imageViewCenterYConstraint.isActive = false + imageViewWidthConstraint = imageContainerView.set(.width, to: size.imageSize) + imageViewHeightConstraint = imageContainerView.set(.height, to: size.imageSize) + additionalImageContainerView.pin(.trailing, to: .trailing, of: self) + additionalImageContainerView.pin(.bottom, to: .bottom, of: self) + additionalImageViewWidthConstraint = additionalImageContainerView.set(.width, to: size.multiImageSize) + additionalImageViewHeightConstraint = additionalImageContainerView.set(.height, to: size.multiImageSize) + + imageContainerView.addSubview(imageView) + imageContainerView.addSubview(animatedImageView) + additionalImageContainerView.addSubview(additionalImageView) + additionalImageContainerView.addSubview(additionalAnimatedImageView) + + // Activate the image edge constraints + imageEdgeConstraints.forEach { $0.isActive = true } + additionalImageEdgeConstraints.forEach { $0.isActive = true } + + profileIconTopConstraint = profileIconImageView.pin( + .top, + to: .top, + of: profileIconBackgroundView, + withInset: 0 + ) + profileIconImageView.pin(.left, to: .left, of: profileIconBackgroundView) + profileIconImageView.pin(.right, to: .right, of: profileIconBackgroundView) + profileIconBottomConstraint = profileIconImageView.pin( + .bottom, + to: .bottom, + of: profileIconBackgroundView, + withInset: 0 + ) + profileIconBackgroundLeftAlignConstraint = profileIconBackgroundView.pin(.leading, to: .leading, of: imageContainerView) + profileIconBackgroundRightAlignConstraint = profileIconBackgroundView.pin(.trailing, to: .trailing, of: imageContainerView) + profileIconBackgroundView.pin(.bottom, to: .bottom, of: imageContainerView) + profileIconBackgroundWidthConstraint = profileIconBackgroundView.set(.width, to: size.iconSize) + profileIconBackgroundHeightConstraint = profileIconBackgroundView.set(.height, to: size.iconSize) + profileIconBackgroundLeftAlignConstraint.isActive = false + profileIconBackgroundRightAlignConstraint.isActive = false + + additionalProfileIconTopConstraint = additionalProfileIconImageView.pin( + .top, + to: .top, + of: additionalProfileIconBackgroundView, + withInset: 0 + ) + additionalProfileIconImageView.pin(.left, to: .left, of: additionalProfileIconBackgroundView) + additionalProfileIconImageView.pin(.right, to: .right, of: additionalProfileIconBackgroundView) + additionalProfileIconBottomConstraint = additionalProfileIconImageView.pin( + .bottom, + to: .bottom, + of: additionalProfileIconBackgroundView, + withInset: 0 + ) + additionalProfileIconBackgroundLeftAlignConstraint = additionalProfileIconBackgroundView.pin(.leading, to: .leading, of: additionalImageContainerView) + additionalProfileIconBackgroundRightAlignConstraint = additionalProfileIconBackgroundView.pin(.trailing, to: .trailing, of: additionalImageContainerView) + additionalProfileIconBackgroundView.pin(.bottom, to: .bottom, of: additionalImageContainerView) + additionalProfileIconBackgroundWidthConstraint = additionalProfileIconBackgroundView.set(.width, to: size.iconSize) + additionalProfileIconBackgroundHeightConstraint = additionalProfileIconBackgroundView.set(.height, to: size.iconSize) + additionalProfileIconBackgroundLeftAlignConstraint.isActive = false + additionalProfileIconBackgroundRightAlignConstraint.isActive = false + } + + // MARK: - Content + + private func updateIconView( + icon: ProfileIcon, + imageView: UIImageView, + backgroundView: UIView, + topConstraint: NSLayoutConstraint, + leftAlignConstraint: NSLayoutConstraint, + rightAlignConstraint: NSLayoutConstraint, + bottomConstraint: NSLayoutConstraint + ) { + backgroundView.isHidden = (icon == .none) + leftAlignConstraint.isActive = ( + icon == .none || + icon == .crown + ) + rightAlignConstraint.isActive = ( + icon == .rightPlus + ) + topConstraint.constant = icon.iconVerticalInset(for: size) + bottomConstraint.constant = -icon.iconVerticalInset(for: size) + + switch icon { + case .none: imageView.image = nil + + case .crown: + imageView.image = UIImage(systemName: "crown.fill") + backgroundView.themeBackgroundColor = .profileIcon_background + + ThemeManager.onThemeChange(observer: imageView) { [weak imageView] _, primaryColor in + let targetColor: ThemeValue = (primaryColor == .green ? + .profileIcon_greenPrimaryColor : + .profileIcon + ) + + guard imageView?.themeTintColor != targetColor else { return } + + imageView?.themeTintColor = targetColor + } + + case .rightPlus: + imageView.image = UIImage( + systemName: "plus", + withConfiguration: UIImage.SymbolConfiguration(weight: .semibold) + ) + imageView.themeTintColor = .black + backgroundView.themeBackgroundColor = .primary + } + } + + // MARK: - Content + + private func prepareForReuse() { + imageView.contentMode = .scaleAspectFill + imageView.isHidden = true + animatedImageView.contentMode = .scaleAspectFill + animatedImageView.isHidden = true + imageContainerView.clipsToBounds = clipsToBounds + imageContainerView.themeBackgroundColor = .backgroundSecondary + additionalImageContainerView.isHidden = true + animatedImageView.image = nil + additionalImageView.image = nil + additionalAnimatedImageView.image = nil + additionalImageView.isHidden = true + additionalAnimatedImageView.isHidden = true + additionalImageContainerView.clipsToBounds = clipsToBounds + + imageViewTopConstraint.isActive = false + imageViewLeadingConstraint.isActive = false + imageViewCenterXConstraint.isActive = true + imageViewCenterYConstraint.isActive = true + profileIconBackgroundView.isHidden = true + profileIconBackgroundLeftAlignConstraint.isActive = false + profileIconBackgroundRightAlignConstraint.isActive = false + additionalProfileIconBackgroundView.isHidden = true + additionalProfileIconBackgroundLeftAlignConstraint.isActive = false + additionalProfileIconBackgroundRightAlignConstraint.isActive = false + imageEdgeConstraints.forEach { $0.constant = 0 } + additionalImageEdgeConstraints.forEach { $0.constant = 0 } + } + + public func update( + _ info: Info, + additionalInfo: Info? = nil + ) { + prepareForReuse() + + // Sort out the icon first + updateIconView( + icon: info.icon, + imageView: profileIconImageView, + backgroundView: profileIconBackgroundView, + topConstraint: profileIconTopConstraint, + leftAlignConstraint: profileIconBackgroundLeftAlignConstraint, + rightAlignConstraint: profileIconBackgroundRightAlignConstraint, + bottomConstraint: profileIconBottomConstraint + ) + + // Populate the main imageView + switch info.imageData?.guessedImageFormat { + case .gif, .webp: animatedImageView.image = info.imageData.map { YYImage(data: $0) } + default: + imageView.image = info.imageData + .map { + guard info.renderingMode != .automatic else { return UIImage(data: $0) } + + return UIImage(data: $0)?.withRenderingMode(info.renderingMode) + } + } + + imageView.themeTintColor = info.themeTintColor + imageView.isHidden = (imageView.image == nil) + animatedImageView.themeTintColor = info.themeTintColor + animatedImageView.isHidden = (animatedImageView.image == nil) + imageContainerView.themeBackgroundColor = info.backgroundColor + imageContainerView.themeBackgroundColorForced = info.forcedBackgroundColor + profileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) + imageEdgeConstraints.enumerated().forEach { index, constraint in + switch index % 4 { + case 0: constraint.constant = info.inset.top + case 1: constraint.constant = info.inset.left + case 2: constraint.constant = -info.inset.bottom + case 3: constraint.constant = -info.inset.right + default: break + } + } + + // Check if there is a second image (if not then set the size and finish) + guard let additionalInfo: Info = additionalInfo else { + imageViewWidthConstraint.constant = size.imageSize + imageViewHeightConstraint.constant = size.imageSize + imageContainerView.layer.cornerRadius = (imageContainerView.clipsToBounds ? (size.imageSize / 2) : 0) + return + } + + // Sort out the additional icon first + updateIconView( + icon: additionalInfo.icon, + imageView: additionalProfileIconImageView, + backgroundView: additionalProfileIconBackgroundView, + topConstraint: additionalProfileIconTopConstraint, + leftAlignConstraint: additionalProfileIconBackgroundLeftAlignConstraint, + rightAlignConstraint: additionalProfileIconBackgroundRightAlignConstraint, + bottomConstraint: additionalProfileIconBottomConstraint + ) + + // Set the additional image content and reposition the image views correctly + switch additionalInfo.imageData?.guessedImageFormat { + case .gif, .webp: additionalAnimatedImageView.image = additionalInfo.imageData.map { YYImage(data: $0) } + default: + additionalImageView.image = additionalInfo.imageData + .map { + guard additionalInfo.renderingMode != .automatic else { return UIImage(data: $0) } + + return UIImage(data: $0)?.withRenderingMode(additionalInfo.renderingMode) + } + } + + additionalImageView.themeTintColor = additionalInfo.themeTintColor + additionalImageView.isHidden = (additionalImageView.image == nil) + additionalAnimatedImageView.themeTintColor = additionalInfo.themeTintColor + additionalAnimatedImageView.isHidden = (additionalAnimatedImageView.image == nil) + additionalImageContainerView.isHidden = false + + switch (info.backgroundColor, info.forcedBackgroundColor) { + case (_, .some(let color)): additionalImageContainerView.themeBackgroundColorForced = color + case (.some(let color), _): additionalImageContainerView.themeBackgroundColor = color + default: additionalImageContainerView.themeBackgroundColor = .primary + } + + additionalImageEdgeConstraints.enumerated().forEach { index, constraint in + switch index % 4 { + case 0: constraint.constant = additionalInfo.inset.top + case 1: constraint.constant = additionalInfo.inset.left + case 2: constraint.constant = -additionalInfo.inset.bottom + case 3: constraint.constant = -additionalInfo.inset.right + default: break + } + } + + imageViewTopConstraint.isActive = true + imageViewLeadingConstraint.isActive = true + imageViewCenterXConstraint.isActive = false + imageViewCenterYConstraint.isActive = false + + imageViewWidthConstraint.constant = size.multiImageSize + imageViewHeightConstraint.constant = size.multiImageSize + imageContainerView.layer.cornerRadius = (imageContainerView.clipsToBounds ? (size.multiImageSize / 2) : 0) + additionalImageViewWidthConstraint.constant = size.multiImageSize + additionalImageViewHeightConstraint.constant = size.multiImageSize + additionalImageContainerView.layer.cornerRadius = (additionalImageContainerView.clipsToBounds ? + (size.multiImageSize / 2) : + 0 + ) + additionalProfileIconBackgroundView.layer.cornerRadius = (size.iconSize / 2) + } +} diff --git a/SessionUIKit/Components/Separator.swift b/SessionUIKit/Components/Separator.swift index 178c27748..d35add8fd 100644 --- a/SessionUIKit/Components/Separator.swift +++ b/SessionUIKit/Components/Separator.swift @@ -3,7 +3,7 @@ import UIKit public final class Separator: UIView { - private static let height: CGFloat = 24 + public static let height: CGFloat = 32 // MARK: - Components @@ -25,7 +25,6 @@ public final class Separator: UIView { private lazy var titleLabel: UILabel = { let result = UILabel() - result.setContentCompressionResistancePriority(.required, for: .vertical) result.font = .systemFont(ofSize: Values.smallFontSize) result.themeTextColor = .textSecondary result.textAlignment = .center diff --git a/SessionUIKit/Components/SessionButton.swift b/SessionUIKit/Components/SessionButton.swift index f7acab456..f30ca2ede 100644 --- a/SessionUIKit/Components/SessionButton.swift +++ b/SessionUIKit/Components/SessionButton.swift @@ -104,9 +104,9 @@ public final class SessionButton: UIButton { clipsToBounds = true contentEdgeInsets = UIEdgeInsets( top: 0, - left: Values.smallSpacing, + left: Values.largeSpacing, bottom: 0, - right: Values.smallSpacing + right: Values.largeSpacing ) titleLabel?.font = .boldSystemFont(ofSize: (size == .small ? Values.smallFontSize : @@ -140,12 +140,23 @@ public final class SessionButton: UIButton { }(), for: .normal ) + setThemeTitleColor( + { + switch style { + case .borderless: return .highlighted(.sessionButton_text) + case .destructiveBorderless: return .highlighted(.sessionButton_destructiveText) + case .bordered, .destructive, .filled: return nil + } + }(), + for: .highlighted + ) setThemeBackgroundColor( { switch style { - case .bordered, .borderless: return .sessionButton_background - case .destructive, .destructiveBorderless: return .sessionButton_destructiveBackground + case .bordered: return .sessionButton_background + case .destructive: return .sessionButton_destructiveBackground + case .borderless, .destructiveBorderless: return .clear case .filled: return .sessionButton_filledBackground } }(), @@ -154,8 +165,9 @@ public final class SessionButton: UIButton { setThemeBackgroundColor( { switch style { - case .bordered, .borderless: return .sessionButton_highlight - case .destructive, .destructiveBorderless: return .sessionButton_destructiveHighlight + case .bordered: return .sessionButton_highlight + case .destructive: return .sessionButton_destructiveHighlight + case .borderless, .destructiveBorderless: return nil case .filled: return .sessionButton_filledHighlight } }(), diff --git a/SessionUIKit/Components/TopBannerController.swift b/SessionUIKit/Components/TopBannerController.swift new file mode 100644 index 000000000..f00e53115 --- /dev/null +++ b/SessionUIKit/Components/TopBannerController.swift @@ -0,0 +1,204 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUtilitiesKit + +public class TopBannerController: UIViewController { + public enum Warning: String, Codable { + case outdatedUserConfig + + var text: String { + switch self { + case .outdatedUserConfig: return "USER_CONFIG_OUTDATED_WARNING".localized() + } + } + } + + private static var lastInstance: TopBannerController? + private let child: UIViewController + private var initialCachedWarning: Warning? + + // MARK: - UI + + private lazy var bottomConstraint: NSLayoutConstraint = bannerLabel + .pin(.bottom, to: .bottom, of: bannerContainer, withInset: -Values.verySmallSpacing) + + private let contentStackView: UIStackView = { + let result: UIStackView = UIStackView() + result.translatesAutoresizingMaskIntoConstraints = false + result.axis = .vertical + result.distribution = .fill + result.alignment = .fill + + return result + }() + + private let bannerContainer: UIView = { + let result: UIView = UIView() + result.translatesAutoresizingMaskIntoConstraints = false + result.themeBackgroundColor = .primary + result.isHidden = true + + return result + }() + + private let bannerLabel: UILabel = { + let result: UILabel = UILabel() + result.translatesAutoresizingMaskIntoConstraints = false + result.setContentHuggingPriority(.required, for: .vertical) + result.font = .systemFont(ofSize: Values.verySmallFontSize) + result.textAlignment = .center + result.themeTextColor = .black + result.numberOfLines = 0 + + return result + }() + + private lazy var closeButton: UIButton = { + let result: UIButton = UIButton() + result.translatesAutoresizingMaskIntoConstraints = false + result.setImage( + UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .bold))? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) + result.contentMode = .center + result.themeTintColor = .black + result.addTarget(self, action: #selector(dismissBanner), for: .touchUpInside) + + return result + }() + + // MARK: - Initialization + + public init( + child: UIViewController, + cachedWarning: Warning? = nil + ) { + self.child = child + self.initialCachedWarning = cachedWarning + + super.init(nibName: nil, bundle: nil) + + TopBannerController.lastInstance = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func loadView() { + super.loadView() + + view.addSubview(contentStackView) + + contentStackView.addArrangedSubview(bannerContainer) + + child.willMove(toParent: self) + addChild(child) + contentStackView.addArrangedSubview(child.view) + child.didMove(toParent: self) + + bannerContainer.addSubview(bannerLabel) + bannerContainer.addSubview(closeButton) + + setupLayout() + + // If we had an initial warning then show it + if let warning: Warning = self.initialCachedWarning { + UIView.performWithoutAnimation { + TopBannerController.show(warning: warning) + } + + self.initialCachedWarning = nil + } + } + + private func setupLayout() { + contentStackView.pin(.top, to: .top, of: view.safeAreaLayoutGuide) + contentStackView.pin(.leading, to: .leading, of: view) + contentStackView.pin(.trailing, to: .trailing, of: view) + contentStackView.pin(.bottom, to: .bottom, of: view) + + bannerLabel.pin(.top, to: .top, of: view.safeAreaLayoutGuide, withInset: Values.verySmallSpacing) + bannerLabel.pin(.leading, to: .leading, of: bannerContainer, withInset: Values.veryLargeSpacing) + bannerLabel.pin(.trailing, to: .trailing, of: bannerContainer, withInset: -Values.veryLargeSpacing) + bottomConstraint.isActive = false + + let buttonSize: CGFloat = (12 + (Values.smallSpacing * 2)) + closeButton.center(.vertical, in: bannerLabel) + closeButton.pin(.trailing, to: .trailing, of: bannerContainer, withInset: -Values.smallSpacing) + closeButton.set(.width, to: buttonSize) + closeButton.set(.height, to: buttonSize) + } + + // MARK: - Actions + + @objc private func dismissBanner() { + // Remove the cached warning + UserDefaults.sharedLokiProject?[.topBannerWarningToShow] = nil + + UIView.animate( + withDuration: 0.3, + animations: { [weak self] in + self?.bottomConstraint.isActive = false + self?.contentStackView.setNeedsLayout() + self?.contentStackView.layoutIfNeeded() + }, + completion: { [weak self] _ in + self?.bannerContainer.isHidden = true + } + ) + } + + // MARK: - Functions + + public static func show(warning: Warning, inWindowFor view: UIView? = nil) { + guard Thread.isMainThread else { + DispatchQueue.main.async { + TopBannerController.show(warning: warning, inWindowFor: view) + } + return + } + + // Not an ideal approach but should allow us to have a single banner + guard let instance: TopBannerController = ((view?.window?.rootViewController as? TopBannerController) ?? TopBannerController.lastInstance) else { + return + } + + // Cache the banner to show (so we can show it on re-launch) + UserDefaults.sharedLokiProject?[.topBannerWarningToShow] = warning.rawValue + + UIView.performWithoutAnimation { + instance.bannerLabel.text = warning.text + instance.bannerLabel.setNeedsLayout() + instance.bannerLabel.layoutIfNeeded() + instance.bottomConstraint.isActive = false + instance.bannerContainer.isHidden = false + } + + UIView.animate(withDuration: 0.3) { [weak instance] in + instance?.bottomConstraint.isActive = true + instance?.contentStackView.setNeedsLayout() + instance?.contentStackView.layoutIfNeeded() + } + } + + public static func hide(inWindowFor view: UIView? = nil) { + guard Thread.isMainThread else { + DispatchQueue.main.async { + TopBannerController.hide(inWindowFor: view) + } + return + } + + // Not an ideal approach but should allow us to have a single banner + guard let instance: TopBannerController = ((view?.window?.rootViewController as? TopBannerController) ?? TopBannerController.lastInstance) else { + return + } + + UIView.performWithoutAnimation { instance.dismissBanner() } + } +} diff --git a/SessionUIKit/Configuration.swift b/SessionUIKit/Configuration.swift index f5b2a6186..798ba98eb 100644 --- a/SessionUIKit/Configuration.swift +++ b/SessionUIKit/Configuration.swift @@ -1,10 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB import SessionUtilitiesKit -public enum SNUIKit { - public static func migrations() -> TargetMigrations { +public enum SNUIKit: MigratableTarget { + public static func migrations(_ db: Database) -> TargetMigrations { return TargetMigrations( identifier: .uiKit, migrations: [ @@ -15,7 +16,7 @@ public enum SNUIKit { [], // YDB Removal [ _001_ThemePreferences.self - ] + ] // Add job priorities ] ) } diff --git a/SessionUIKit/Database/Migrations/_001_ThemePreferences.swift b/SessionUIKit/Database/Migrations/_001_ThemePreferences.swift index 39ca24f5e..2951dca42 100644 --- a/SessionUIKit/Database/Migrations/_001_ThemePreferences.swift +++ b/SessionUIKit/Database/Migrations/_001_ThemePreferences.swift @@ -35,10 +35,12 @@ enum _001_ThemePreferences: Migration { db[.themePrimaryColor] = targetPrimaryColor // Looks like the ThemeManager will load it's default values before this migration gets run - // as a result we need to update the ThemeManage to ensure the correct theme is applied - ThemeManager.currentTheme = targetTheme - ThemeManager.primaryColor = targetPrimaryColor - ThemeManager.matchSystemNightModeSetting = matchSystemNightModeSetting + // as a result we need to update the ThemeManager to ensure the correct theme is applied + ThemeManager.setInitialThemeState( + theme: targetTheme, + primaryColor: targetPrimaryColor, + matchSystemNightModeSetting: matchSystemNightModeSetting + ) Storage.update(progress: 1, for: self, in: target) // In case this is the last migration } diff --git a/SessionUIKit/Style Guide/Format.swift b/SessionUIKit/Style Guide/Format.swift new file mode 100644 index 000000000..ab25db8e5 --- /dev/null +++ b/SessionUIKit/Style Guide/Format.swift @@ -0,0 +1,32 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum Format { + private static let fileSizeFormatter: NumberFormatter = { + let result: NumberFormatter = NumberFormatter() + result.numberStyle = .decimal + result.minimumFractionDigits = 0 + result.maximumFractionDigits = 1 + + return result + }() + private static let oneKilobyte: Double = 1024; + private static let oneMegabyte: Double = (oneKilobyte * oneKilobyte) + + public static func fileSize(_ fileSize: UInt) -> String { + let fileSizeDouble: Double = Double(fileSize) + + switch fileSizeDouble { + case oneMegabyte...Double.greatestFiniteMagnitude: + return (Format.fileSizeFormatter + .string(from: NSNumber(floatLiteral: (fileSizeDouble / oneMegabyte)))? + .appending("MB") ?? "n/a") + + default: + return (Format.fileSizeFormatter + .string(from: NSNumber(floatLiteral: max(0.1, (fileSizeDouble / oneKilobyte))))? + .appending("KB") ?? "n/a") + } + } +} diff --git a/SessionUIKit/Style Guide/ThemeManager.swift b/SessionUIKit/Style Guide/ThemeManager.swift index fa165e996..a38356ebb 100644 --- a/SessionUIKit/Style Guide/ThemeManager.swift +++ b/SessionUIKit/Style Guide/ThemeManager.swift @@ -30,8 +30,12 @@ public enum ThemeManager { /// Unfortunately if we don't do this the `ThemeApplier` is immediately deallocated and we can't use it to update the theme private static var uiRegistry: NSMapTable = NSMapTable.weakToStrongObjects() + private static var _initialTheme: Theme? + private static var _initialPrimaryColor: Theme.PrimaryColor? + private static var _initialMatchSystemNightModeSetting: Bool? + public static var currentTheme: Theme = { - Storage.shared[.theme].defaulting(to: Theme.classicDark) + (_initialTheme ?? Storage.shared[.theme].defaulting(to: Theme.classicDark)) }() { didSet { // Only update if it was changed @@ -55,7 +59,7 @@ public enum ThemeManager { } public static var primaryColor: Theme.PrimaryColor = { - Storage.shared[.themePrimaryColor].defaulting(to: Theme.PrimaryColor.green) + (_initialPrimaryColor ?? Storage.shared[.themePrimaryColor].defaulting(to: Theme.PrimaryColor.green)) }() { didSet { // Only update if it was changed @@ -70,7 +74,7 @@ public enum ThemeManager { } public static var matchSystemNightModeSetting: Bool = { - Storage.shared[.themeMatchSystemDayNightCycle] + (_initialMatchSystemNightModeSetting ?? Storage.shared[.themeMatchSystemDayNightCycle]) }() { didSet { // Only update if it was changed @@ -99,6 +103,16 @@ public enum ThemeManager { // MARK: - Functions + public static func setInitialThemeState( + theme: Theme, + primaryColor: Theme.PrimaryColor, + matchSystemNightModeSetting: Bool + ) { + _initialTheme = theme + _initialPrimaryColor = primaryColor + _initialMatchSystemNightModeSetting = matchSystemNightModeSetting + } + public static func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { let currentUserInterfaceStyle: UIUserInterfaceStyle = UITraitCollection.current.userInterfaceStyle @@ -407,8 +421,10 @@ internal class ThemeApplier { .compactMap { $0?.clearingOtherAppliers() } .filter { $0.info != info } - // Automatically apply the theme immediately - self.apply(theme: ThemeManager.currentTheme, isInitialApplication: true) + // Automatically apply the theme immediately (if the database has been setup) + if Storage.hasCreatedValidInstance { + self.apply(theme: ThemeManager.currentTheme, isInitialApplication: true) + } } // MARK: - Functions diff --git a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift index 69242e54b..1bd0e039a 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift @@ -69,6 +69,7 @@ internal enum Theme_ClassicDark: ThemeColors { .solidButton_background: .classicDark3, // Settings + .settings_tertiaryAction: .primary, .settings_tabBackground: .classicDark1, // Appearance @@ -89,6 +90,7 @@ internal enum Theme_ClassicDark: ThemeColors { .conversationButton_swipeDestructive: .dangerDark, .conversationButton_swipeSecondary: .classicDark2, .conversationButton_swipeTertiary: Theme.PrimaryColor.orange.color, + .conversationButton_swipeRead: .classicDark3, // InputButton .inputButton_background: .classicDark2, @@ -108,6 +110,14 @@ internal enum Theme_ClassicDark: ThemeColors { .reactions_contextMoreBackground: .classicDark1, // NewConversation - .newConversation_background: .classicDark1 + .newConversation_background: .classicDark1, + + // Profile + .profileIcon: .primary, + .profileIcon_greenPrimaryColor: .black, + .profileIcon_background: .white, + + // Unread Marker + .unreadMarker: .primary ] } diff --git a/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift b/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift index f1e1a9d80..b659a95e0 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift @@ -69,6 +69,7 @@ internal enum Theme_ClassicLight: ThemeColors { .solidButton_background: .classicLight3, // Settings + .settings_tertiaryAction: .classicLight0, .settings_tabBackground: .classicLight5, // AppearanceButton @@ -89,6 +90,7 @@ internal enum Theme_ClassicLight: ThemeColors { .conversationButton_swipeDestructive: .dangerLight, .conversationButton_swipeSecondary: .classicLight1, .conversationButton_swipeTertiary: Theme.PrimaryColor.orange.color, + .conversationButton_swipeRead: .classicLight3, // InputButton .inputButton_background: .classicLight4, @@ -108,6 +110,14 @@ internal enum Theme_ClassicLight: ThemeColors { .reactions_contextMoreBackground: .classicLight6, // NewConversation - .newConversation_background: .classicLight6 + .newConversation_background: .classicLight6, + + // Profile + .profileIcon: .primary, + .profileIcon_greenPrimaryColor: .primary, + .profileIcon_background: .black, + + // Unread Marker + .unreadMarker: .black ] } diff --git a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift index beb4cba54..a87cf4d4d 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift @@ -69,6 +69,7 @@ internal enum Theme_OceanDark: ThemeColors { .solidButton_background: .oceanDark2, // Settings + .settings_tertiaryAction: .primary, .settings_tabBackground: .oceanDark1, // Appearance @@ -89,6 +90,7 @@ internal enum Theme_OceanDark: ThemeColors { .conversationButton_swipeDestructive: .dangerDark, .conversationButton_swipeSecondary: .oceanDark2, .conversationButton_swipeTertiary: Theme.PrimaryColor.orange.color, + .conversationButton_swipeRead: .primary, // InputButton .inputButton_background: .oceanDark4, @@ -108,6 +110,14 @@ internal enum Theme_OceanDark: ThemeColors { .reactions_contextMoreBackground: .oceanDark2, // NewConversation - .newConversation_background: .oceanDark3 + .newConversation_background: .oceanDark3, + + // Profile + .profileIcon: .primary, + .profileIcon_greenPrimaryColor: .black, + .profileIcon_background: .white, + + // Unread Marker + .unreadMarker: .primary ] } diff --git a/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift b/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift index c1e72799e..ec4df6764 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift @@ -69,6 +69,7 @@ internal enum Theme_OceanLight: ThemeColors { .solidButton_background: .oceanLight5, // Settings + .settings_tertiaryAction: .oceanLight1, .settings_tabBackground: .oceanLight6, // Appearance @@ -89,6 +90,7 @@ internal enum Theme_OceanLight: ThemeColors { .conversationButton_swipeDestructive: .dangerLight, .conversationButton_swipeSecondary: .oceanLight2, .conversationButton_swipeTertiary: Theme.PrimaryColor.orange.color, + .conversationButton_swipeRead: .primary, // InputButton .inputButton_background: .oceanLight5, @@ -108,6 +110,14 @@ internal enum Theme_OceanLight: ThemeColors { .reactions_contextMoreBackground: .oceanLight6, // NewConversation - .newConversation_background: .oceanLight7 + .newConversation_background: .oceanLight7, + + // Profile + .profileIcon: .primary, + .profileIcon_greenPrimaryColor: .primary, + .profileIcon_background: .oceanLight1, + + // Unread Marker + .unreadMarker: .black ] } diff --git a/SessionUIKit/Style Guide/Themes/Theme.swift b/SessionUIKit/Style Guide/Themes/Theme.swift index f2766cc6d..d34d1a702 100644 --- a/SessionUIKit/Style Guide/Themes/Theme.swift +++ b/SessionUIKit/Style Guide/Themes/Theme.swift @@ -157,6 +157,7 @@ public indirect enum ThemeValue: Hashable { case solidButton_background // Settings + case settings_tertiaryAction case settings_tabBackground // Appearance @@ -177,6 +178,7 @@ public indirect enum ThemeValue: Hashable { case conversationButton_swipeDestructive case conversationButton_swipeSecondary case conversationButton_swipeTertiary + case conversationButton_swipeRead // InputButton case inputButton_background @@ -197,6 +199,14 @@ public indirect enum ThemeValue: Hashable { // NewConversation case newConversation_background + + // Profile + case profileIcon + case profileIcon_greenPrimaryColor + case profileIcon_background + + // Unread Marker + case unreadMarker } // MARK: - ForcedThemeValue diff --git a/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift b/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift index 97916601d..2948a4b5f 100644 --- a/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift +++ b/SessionUIKit/Style Guide/Themes/UIKit+Theme.swift @@ -447,7 +447,16 @@ public extension UIToolbar { public extension UIContextualAction { var themeBackgroundColor: ThemeValue? { - set { ThemeManager.set(self, keyPath: \.backgroundColor, to: newValue) } + set { + guard let newValue: ThemeValue = newValue else { + self.backgroundColor = nil + return + } + + self.backgroundColor = UIColor(dynamicProvider: { _ in + (ThemeManager.currentTheme.color(for: newValue) ?? .clear) + }) + } get { return nil } } } diff --git a/SessionUIKit/Style Guide/Values.swift b/SessionUIKit/Style Guide/Values.swift index a66d47801..78b65c565 100644 --- a/SessionUIKit/Style Guide/Values.swift +++ b/SessionUIKit/Style Guide/Values.swift @@ -25,11 +25,6 @@ public final class Values : NSObject { @objc public static let accentLineThickness = CGFloat(4) - @objc public static let verySmallProfilePictureSize = CGFloat(26) - @objc public static let smallProfilePictureSize = CGFloat(33) - @objc public static let mediumProfilePictureSize = CGFloat(45) - @objc public static let largeProfilePictureSize = CGFloat(75) - @objc public static let searchBarHeight = CGFloat(36) @objc public static var separatorThickness: CGFloat { return 1 / UIScreen.main.scale } @@ -54,7 +49,7 @@ public final class Values : NSObject { // MARK: - iPad Sizes @objc public static let iPadModalWidth = UIScreen.main.bounds.width / 2 - @objc public static let iPadButtonWidth = CGFloat(196) + @objc public static let iPadButtonWidth = CGFloat(240) @objc public static let iPadButtonSpacing = CGFloat(32) @objc public static let iPadUserSessionIdContainerWidth = iPadButtonWidth * 2 + iPadButtonSpacing } diff --git a/SessionUIKit/Types/Accessibility.swift b/SessionUIKit/Types/Accessibility.swift new file mode 100644 index 000000000..ef1d2ff02 --- /dev/null +++ b/SessionUIKit/Types/Accessibility.swift @@ -0,0 +1,16 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct Accessibility: Hashable, Equatable { + public let identifier: String? + public let label: String? + + public init( + identifier: String? = nil, + label: String? = nil + ) { + self.identifier = identifier + self.label = label + } +} diff --git a/SessionUIKit/Types/IconSize.swift b/SessionUIKit/Types/IconSize.swift index 3735676f5..c652e63ce 100644 --- a/SessionUIKit/Types/IconSize.swift +++ b/SessionUIKit/Types/IconSize.swift @@ -4,19 +4,23 @@ import Foundation import DifferenceKit public enum IconSize: Differentiable { + case verySmall case small case medium case large case veryLarge + case extraLarge case fit public var size: CGFloat { switch self { + case .verySmall: return 12 case .small: return 20 case .medium: return 24 case .large: return 32 - case .veryLarge: return 80 + case .veryLarge: return 40 + case .extraLarge: return 80 case .fit: return 0 } } diff --git a/SessionUIKit/Utilities/UIContextualAction+Theming.swift b/SessionUIKit/Utilities/UIContextualAction+Theming.swift index 4e4396c1e..ab5bff7ee 100644 --- a/SessionUIKit/Utilities/UIContextualAction+Theming.swift +++ b/SessionUIKit/Utilities/UIContextualAction+Theming.swift @@ -69,10 +69,9 @@ public extension UIContextualAction { stackView.spacing = 4 if let icon: UIImage = icon { - let scale: Double = iconHeight / icon.size.height let aspectRatio: CGFloat = (icon.size.width / icon.size.height) let imageView: UIImageView = UIImageView(image: icon) - imageView.frame = CGRect(x: 0, y: 0, width: iconHeight * aspectRatio, height: iconHeight) + imageView.frame = CGRect(x: 0, y: 0, width: (iconHeight * aspectRatio), height: iconHeight) imageView.contentMode = .scaleAspectFit imageView.themeTintColor = themeTintColor stackView.addArrangedSubview(imageView) @@ -80,7 +79,7 @@ public extension UIContextualAction { if let title: String = title { let label: UILabel = UILabel() - label.font = .systemFont(ofSize: Values.verySmallFontSize) + label.font = .systemFont(ofSize: Values.smallFontSize) label.text = title label.textAlignment = .center label.themeTextColor = themeTintColor diff --git a/SessionUIKit/Utilities/UIView+Constraints.swift b/SessionUIKit/Utilities/UIView+Constraints.swift index 2ab9187d2..dbeee0877 100644 --- a/SessionUIKit/Utilities/UIView+Constraints.swift +++ b/SessionUIKit/Utilities/UIView+Constraints.swift @@ -76,6 +76,18 @@ public extension Anchorable { .setting(isActive: true) } + @discardableResult + func pin(_ constraineeEdge: UIView.HorizontalEdge, greaterThanOrEqualTo constrainerEdge: UIView.HorizontalEdge, of anchorable: Anchorable, withInset inset: CGFloat = 0) -> NSLayoutConstraint { + (self as? UIView)?.translatesAutoresizingMaskIntoConstraints = false + + return anchor(from: constraineeEdge) + .constraint( + greaterThanOrEqualTo: anchorable.anchor(from: constrainerEdge), + constant: inset + ) + .setting(isActive: true) + } + @discardableResult func pin(_ constraineeEdge: UIView.VerticalEdge, to constrainerEdge: UIView.VerticalEdge, of anchorable: Anchorable, withInset inset: CGFloat = 0) -> NSLayoutConstraint { (self as? UIView)?.translatesAutoresizingMaskIntoConstraints = false @@ -87,6 +99,18 @@ public extension Anchorable { ) .setting(isActive: true) } + + @discardableResult + func pin(_ constraineeEdge: UIView.VerticalEdge, greaterThanOrEqualTo constrainerEdge: UIView.VerticalEdge, of anchorable: Anchorable, withInset inset: CGFloat = 0) -> NSLayoutConstraint { + (self as? UIView)?.translatesAutoresizingMaskIntoConstraints = false + + return anchor(from: constraineeEdge) + .constraint( + greaterThanOrEqualTo: anchorable.anchor(from: constrainerEdge), + constant: inset + ) + .setting(isActive: true) + } } // MARK: - View extensions diff --git a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift index e698c9630..796e9b29d 100644 --- a/SessionUtilitiesKit/Combine/Publisher+Utilities.swift +++ b/SessionUtilitiesKit/Combine/Publisher+Utilities.swift @@ -25,27 +25,21 @@ public extension Publisher { ) } - /// The standard `.receive(on: DispatchQueue.main)` seems to ocassionally dispatch to the - /// next run loop before emitting data, this method checks if it's running on the main thread already and - /// if so just emits directly rather than routing via `.receive(on:)` - func receiveOnMain(immediately receiveImmediately: Bool = false) -> AnyPublisher { - guard receiveImmediately else { - return self.receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - } - + func tryFlatMap( + maxPublishers: Subscribers.Demand = .unlimited, + _ transform: @escaping (Self.Output) throws -> P + ) -> AnyPublisher where T == P.Output, P : Publisher, P.Failure == Error { return self - .flatMap { value -> AnyPublisher in - guard Thread.isMainThread else { - return Just(value) - .setFailureType(to: Failure.self) - .receive(on: DispatchQueue.main) + .mapError { $0 } + .flatMap(maxPublishers: maxPublishers) { output -> AnyPublisher in + do { + return try transform(output) + .eraseToAnyPublisher() + } + catch { + return Fail(error: error) .eraseToAnyPublisher() } - - return Just(value) - .setFailureType(to: Failure.self) - .eraseToAnyPublisher() } .eraseToAnyPublisher() } @@ -59,4 +53,62 @@ public extension Publisher { return sink(into: targetSubject, includeCompletions: includeCompletions) } + + /// Automatically retains the subscription until it emits a 'completion' event + func sinkUntilComplete( + receiveCompletion: ((Subscribers.Completion) -> Void)? = nil, + receiveValue: ((Output) -> Void)? = nil + ) { + var retainCycle: Cancellable? = nil + retainCycle = self + .sink( + receiveCompletion: { result in + receiveCompletion?(result) + + // Redundant but without reading 'retainCycle' it will warn that the variable + // isn't used + if retainCycle != nil { retainCycle = nil } + }, + receiveValue: (receiveValue ?? { _ in }) + ) + } +} + +public extension AnyPublisher { + /// Converts the publisher to output a Result instead of throwing an error, can be used to ensure a subscription never + /// closes due to a failure + func asResult() -> AnyPublisher, Never> { + self + .map { Result.success($0) } + .catch { Just(Result.failure($0)).eraseToAnyPublisher() } + .eraseToAnyPublisher() + } +} + +// MARK: - Data Decoding + +public extension Publisher where Output == Data, Failure == Error { + func decoded( + as type: R.Type, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { + self + .tryMap { data -> R in try data.decoded(as: type, using: dependencies) } + .eraseToAnyPublisher() + } +} + +public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error { + func decoded( + as type: R.Type, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher<(ResponseInfoType, R), Error> { + self + .tryMap { responseInfo, maybeData -> (ResponseInfoType, R) in + guard let data: Data = maybeData else { throw HTTPError.parsingFailed } + + return (responseInfo, try data.decoded(as: type, using: dependencies)) + } + .eraseToAnyPublisher() + } } diff --git a/SessionUtilitiesKit/Combine/ReplaySubject.swift b/SessionUtilitiesKit/Combine/ReplaySubject.swift index 7648fa989..ad5f1efea 100644 --- a/SessionUtilitiesKit/Combine/ReplaySubject.swift +++ b/SessionUtilitiesKit/Combine/ReplaySubject.swift @@ -9,8 +9,7 @@ public final class ReplaySubject: Subject { private var buffer: [Output] = [Output]() private let bufferSize: Int private let lock: NSRecursiveLock = NSRecursiveLock() - - private var subscriptions = [ReplaySubjectSubscription]() + private var subscriptions: Atomic<[ReplaySubjectSubscription]> = Atomic([]) private var completion: Subscribers.Completion? // MARK: - Initialization @@ -27,7 +26,7 @@ public final class ReplaySubject: Subject { buffer.append(value) buffer = buffer.suffix(bufferSize) - subscriptions.forEach { $0.receive(value) } + subscriptions.wrappedValue.forEach { $0.receive(value) } } /// Sends a completion signal to the subscriber @@ -35,7 +34,7 @@ public final class ReplaySubject: Subject { lock.lock(); defer { lock.unlock() } self.completion = completion - subscriptions.forEach { subscription in subscription.receive(completion: completion) } + subscriptions.wrappedValue.forEach { $0.receive(completion: completion) } } /// Provides this Subject an opportunity to establish demand for any new upstream subscriptions @@ -49,10 +48,23 @@ public final class ReplaySubject: Subject { public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { lock.lock(); defer { lock.unlock() } - let subscription = ReplaySubjectSubscription(downstream: AnySubscriber(subscriber)) + /// According to the below comment the `subscriber.receive(subscription: subscription)` code runs asynchronously + /// which aligns with testing (resulting in the `request(_ newDemand: Subscribers.Demand)` function getting called after this + /// function returns + /// + /// Later in the thread it's mentioned that as of `iOS 13.3` this behaviour changed to be synchronous but as of writing the minimum + /// deployment version is set to `iOS 13.0` which I assume is why we are seeing the async behaviour which results in `receiveValue` + /// not being called in some cases + /// + /// When the project is eventually updated to have a minimum version higher than `iOS 13.3` we should re-test this behaviour to see if + /// we can revert this change + /// + /// https://forums.swift.org/t/combine-receive-on-runloop-main-loses-sent-value-how-can-i-make-it-work/28631/20 + let subscription: ReplaySubjectSubscription = ReplaySubjectSubscription(downstream: AnySubscriber(subscriber)) { [weak self, buffer = buffer, completion = completion] subscription in + self?.subscriptions.mutate { $0.append(subscription) } + subscription.replay(buffer, completion: completion) + } subscriber.receive(subscription: subscription) - subscriptions.append(subscription) - subscription.replay(buffer, completion: completion) } } @@ -62,17 +74,21 @@ public final class ReplaySubjectSubscription: Subscripti private let downstream: AnySubscriber private var isCompleted: Bool = false private var demand: Subscribers.Demand = .none + private var onInitialDemand: ((ReplaySubjectSubscription) -> ())? // MARK: - Initialization - init(downstream: AnySubscriber) { + init(downstream: AnySubscriber, onInitialDemand: @escaping (ReplaySubjectSubscription) -> ()) { self.downstream = downstream + self.onInitialDemand = onInitialDemand } // MARK: - Subscription public func request(_ newDemand: Subscribers.Demand) { demand += newDemand + onInitialDemand?(self) + onInitialDemand = nil } public func cancel() { diff --git a/SessionUtilitiesKit/Configuration.swift b/SessionUtilitiesKit/Configuration.swift index 777929dd8..363e3eec0 100644 --- a/SessionUtilitiesKit/Configuration.swift +++ b/SessionUtilitiesKit/Configuration.swift @@ -1,13 +1,14 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB -public enum SNUtilitiesKit { // Just to make the external API nice +public enum SNUtilitiesKit: MigratableTarget { // Just to make the external API nice public static var isRunningTests: Bool { ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil } - - public static func migrations() -> TargetMigrations { + + public static func migrations(_ db: Database) -> TargetMigrations { return TargetMigrations( identifier: .utilitiesKit, migrations: [ @@ -21,7 +22,9 @@ public enum SNUtilitiesKit { // Just to make the external API nice ], [], // Other DB migrations [], // Legacy DB removal - [] + [ + _004_AddJobPriority.self + ] ] ) } diff --git a/SessionUtilitiesKit/Crypto/AESGCM.swift b/SessionUtilitiesKit/Crypto/AESGCM.swift deleted file mode 100644 index e20c8987d..000000000 --- a/SessionUtilitiesKit/Crypto/AESGCM.swift +++ /dev/null @@ -1,77 +0,0 @@ -import CryptoSwift -import Curve25519Kit - -public enum AESGCM { - public static let gcmTagSize: UInt = 16 - public static let ivSize: UInt = 12 - - public struct EncryptionResult { public let ciphertext: Data, symmetricKey: Data, ephemeralPublicKey: Data } - - public enum Error : LocalizedError { - case keyPairGenerationFailed - case sharedSecretGenerationFailed - - public var errorDescription: String? { - switch self { - case .keyPairGenerationFailed: return "Couldn't generate a key pair." - case .sharedSecretGenerationFailed: return "Couldn't generate a shared secret." - } - } - } - - /// - Note: Sync. Don't call from the main thread. - public static func generateSymmetricKey(x25519PublicKey: Data, x25519PrivateKey: Data) throws -> Data { - if Thread.isMainThread { - #if DEBUG - preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") - #endif - } - guard let sharedSecret = try? Curve25519.generateSharedSecret(fromPublicKey: x25519PublicKey, privateKey: x25519PrivateKey) else { - throw Error.sharedSecretGenerationFailed - } - let salt = "LOKI" - return try Data(HMAC(key: salt.bytes, variant: .sha256).authenticate(sharedSecret.bytes)) - } - - /// - Note: Sync. Don't call from the main thread. - public static func decrypt(_ ivAndCiphertext: Data, with symmetricKey: Data) throws -> Data { - if Thread.isMainThread { - #if DEBUG - preconditionFailure("It's illegal to call decrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") - #endif - } - let iv = ivAndCiphertext[0.. Data { - if Thread.isMainThread { - #if DEBUG - preconditionFailure("It's illegal to call encrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") - #endif - } - let iv = Data.getSecureRandomData(ofSize: ivSize)! - let gcm = GCM(iv: iv.bytes, tagLength: Int(gcmTagSize), mode: .combined) - let aes = try AES(key: symmetricKey.bytes, blockMode: gcm, padding: .noPadding) - let ciphertext = try aes.encrypt(plaintext.bytes) - return iv + Data(ciphertext) - } - - /// - Note: Sync. Don't call from the main thread. - public static func encrypt(_ plaintext: Data, for hexEncodedX25519PublicKey: String) throws -> EncryptionResult { - if Thread.isMainThread { - #if DEBUG - preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") - #endif - } - let x25519PublicKey = Data(hex: hexEncodedX25519PublicKey) - let ephemeralKeyPair = Curve25519.generateKeyPair() - let symmetricKey = try generateSymmetricKey(x25519PublicKey: x25519PublicKey, x25519PrivateKey: ephemeralKeyPair.privateKey) - let ciphertext = try encrypt(plaintext, with: Data(symmetricKey)) - return EncryptionResult(ciphertext: ciphertext, symmetricKey: Data(symmetricKey), ephemeralPublicKey: ephemeralKeyPair.publicKey) - } -} diff --git a/SessionUtilitiesKit/Crypto/CryptoKit+Utilities.swift b/SessionUtilitiesKit/Crypto/CryptoKit+Utilities.swift new file mode 100644 index 000000000..3967e9eed --- /dev/null +++ b/SessionUtilitiesKit/Crypto/CryptoKit+Utilities.swift @@ -0,0 +1,109 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import CryptoKit +import Curve25519Kit + +public extension Digest { + var bytes: [UInt8] { Array(makeIterator()) } + var data: Data { Data(bytes) } + + var hexString: String { + bytes.map { String(format: "%02X", $0) }.joined() + } +} + +// MARK: - AES.GCM + +public extension AES.GCM { + static let ivSize: Int = 12 + + struct EncryptionResult { + public let ciphertext: Data + public let symmetricKey: Data + public let ephemeralPublicKey: Data + } + + enum Error: LocalizedError { + case keyPairGenerationFailed + case sharedSecretGenerationFailed + + public var errorDescription: String? { + switch self { + case .keyPairGenerationFailed: return "Couldn't generate a key pair." + case .sharedSecretGenerationFailed: return "Couldn't generate a shared secret." + } + } + } + + /// - Note: Sync. Don't call from the main thread. + static func generateSymmetricKey(x25519PublicKey: Data, x25519PrivateKey: Data) throws -> Data { + #if DEBUG + if Thread.isMainThread { + preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") + } + #endif + guard let sharedSecret: Data = try? Curve25519.generateSharedSecret(fromPublicKey: x25519PublicKey, privateKey: x25519PrivateKey) else { + throw Error.sharedSecretGenerationFailed + } + let salt = "LOKI" + + return Data( + HMAC.authenticationCode( + for: sharedSecret, + using: SymmetricKey(data: salt.bytes) + ) + ) + } + + /// - Note: Sync. Don't call from the main thread. + static func decrypt(_ nonceAndCiphertext: Data, with symmetricKey: Data) throws -> Data { + #if DEBUG + if Thread.isMainThread { + preconditionFailure("It's illegal to call decrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") + } + #endif + + return try AES.GCM.open( + try AES.GCM.SealedBox(combined: nonceAndCiphertext), + using: SymmetricKey(data: symmetricKey) + ) + } + + /// - Note: Sync. Don't call from the main thread. + static func encrypt(_ plaintext: Data, with symmetricKey: Data) throws -> Data { + #if DEBUG + if Thread.isMainThread { + preconditionFailure("It's illegal to call encrypt(_:usingAESGCMWithSymmetricKey:) from the main thread.") + } + #endif + + let nonceData: Data = try Randomness.generateRandomBytes(numberBytes: ivSize) + let sealedData: AES.GCM.SealedBox = try AES.GCM.seal( + plaintext, + using: SymmetricKey(data: symmetricKey), + nonce: try AES.GCM.Nonce(data: nonceData) + ) + + guard let cipherText: Data = sealedData.combined else { + throw GeneralError.keyGenerationFailed + } + + return cipherText + } + + /// - Note: Sync. Don't call from the main thread. + static func encrypt(_ plaintext: Data, for hexEncodedX25519PublicKey: String) throws -> EncryptionResult { + #if DEBUG + if Thread.isMainThread { + preconditionFailure("It's illegal to call encrypt(_:forSnode:) from the main thread.") + } + #endif + let x25519PublicKey = Data(hex: hexEncodedX25519PublicKey) + let ephemeralKeyPair = Curve25519.generateKeyPair() + let symmetricKey = try generateSymmetricKey(x25519PublicKey: x25519PublicKey, x25519PrivateKey: ephemeralKeyPair.privateKey) + let ciphertext = try encrypt(plaintext, with: Data(symmetricKey)) + + return EncryptionResult(ciphertext: ciphertext, symmetricKey: Data(symmetricKey), ephemeralPublicKey: ephemeralKeyPair.publicKey) + } +} diff --git a/SessionUtilitiesKit/Crypto/Data+SecureRandom.swift b/SessionUtilitiesKit/Crypto/Data+SecureRandom.swift deleted file mode 100644 index 1ca9749f8..000000000 --- a/SessionUtilitiesKit/Crypto/Data+SecureRandom.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -public extension Data { - - /// Returns `size` bytes of random data generated using the default secure random number generator. See - /// [SecRandomCopyBytes](https://developer.apple.com/documentation/security/1399291-secrandomcopybytes) for more information. - static func getSecureRandomData(ofSize size: UInt) -> Data? { - var data = Data(count: Int(size)) - let result = data.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, Int(size), $0.baseAddress!) } - guard result == errSecSuccess else { return nil } - return data - } -} diff --git a/SessionUtilitiesKit/Crypto/DiffieHellman.swift b/SessionUtilitiesKit/Crypto/DiffieHellman.swift deleted file mode 100644 index cbc7b9950..000000000 --- a/SessionUtilitiesKit/Crypto/DiffieHellman.swift +++ /dev/null @@ -1,49 +0,0 @@ -import CryptoSwift -import Curve25519Kit - -public final class DiffieHellman : NSObject { - public static let ivSize: UInt = 16 - - public enum Error : LocalizedError { - case decryptionFailed - case sharedSecretGenerationFailed - - public var errorDescription: String { - switch self { - case .decryptionFailed: return "Couldn't decrypt data" - case .sharedSecretGenerationFailed: return "Couldn't generate a shared secret." - } - } - } - - private override init() { } - - public static func encrypt(_ plaintext: Data, using symmetricKey: Data) throws -> Data { - let iv = Data.getSecureRandomData(ofSize: ivSize)! - let cbc = CBC(iv: iv.bytes) - let aes = try AES(key: symmetricKey.bytes, blockMode: cbc) - let ciphertext = try aes.encrypt(plaintext.bytes) - let ivAndCiphertext = iv.bytes + ciphertext - return Data(ivAndCiphertext) - } - - public static func encrypt(_ plaintext: Data, publicKey: Data, privateKey: Data) throws -> Data { - guard let symmetricKey = try? Curve25519.generateSharedSecret(fromPublicKey: publicKey, privateKey: privateKey) else { throw Error.sharedSecretGenerationFailed } - return try encrypt(plaintext, using: symmetricKey) - } - - public static func decrypt(_ ivAndCiphertext: Data, using symmetricKey: Data) throws -> Data { - guard ivAndCiphertext.count >= ivSize else { throw Error.decryptionFailed } - let iv = ivAndCiphertext[.. Data { - guard let symmetricKey = try? Curve25519.generateSharedSecret(fromPublicKey: publicKey, privateKey: privateKey) else { throw Error.sharedSecretGenerationFailed } - return try decrypt(ivAndCiphertext, using: symmetricKey) - } -} diff --git a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift b/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift deleted file mode 100644 index 5dd86097d..000000000 --- a/SessionUtilitiesKit/Crypto/ECKeyPair+Hexadecimal.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Curve25519Kit - -public extension ECKeyPair { - - @objc var hexEncodedPrivateKey: String { - return privateKey.map { String(format: "%02hhx", $0) }.joined() - } - - @objc var hexEncodedPublicKey: String { - // Prefixing with 'SessionId.Prefix.standard' is necessary for what seems to be a sort of Signal public key versioning system - return SessionId(.standard, publicKey: publicKey.bytes).hexString - } - - @objc static func isValidHexEncodedPublicKey(candidate: String) -> Bool { - // Note: If the logic in here changes ensure it doesn't break `SessionId.Prefix(from:)` - // Check that it's a valid hexadecimal encoding - guard Hex.isValid(candidate) else { return false } - // Check that it has length 66 and a valid prefix - guard candidate.count == 66 && 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/Hex.swift b/SessionUtilitiesKit/Crypto/Hex.swift index b90a916fa..ade2e838b 100644 --- a/SessionUtilitiesKit/Crypto/Hex.swift +++ b/SessionUtilitiesKit/Crypto/Hex.swift @@ -1,8 +1,82 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation public enum Hex { - public static func isValid(_ string: String) -> Bool { let allowedCharacters = CharacterSet(charactersIn: "0123456789ABCDEF") + return string.uppercased().unicodeScalars.allSatisfy { allowedCharacters.contains($0) } } } + +// MARK: - Data + +public extension Data { + var bytes: [UInt8] { return Array(self) } + + func toHexString() -> String { + return bytes.toHexString() + } + + init(hex: String) { + self.init(Array(hex: hex)) + } +} + +// MARK: - Array + +public extension Array where Element == UInt8 { + init(hex: String) { + self = Array() + self.reserveCapacity(hex.unicodeScalars.lazy.underestimatedCount) + + var buffer: UInt8? + var skip = (hex.hasPrefix("0x") ? 2 : 0) + + for char in hex.unicodeScalars.lazy { + guard skip == 0 else { + skip -= 1 + continue + } + + guard char.value >= 48 && char.value <= 102 else { + removeAll() + return + } + + let v: UInt8 + let c: UInt8 = UInt8(char.value) + + switch c { + case let c where c <= 57: v = c - 48 + case let c where c >= 65 && c <= 70: v = c - 55 + case let c where c >= 97: v = c - 87 + + default: + removeAll() + return + } + + if let b = buffer { + append(b << 4 | v) + buffer = nil + } + else { + buffer = v + } + } + + if let b = buffer { + append(b) + } + } + + func toHexString() -> String { + return map { String(format: "%02x", $0) }.joined() + } + + func toBase64(options: Data.Base64EncodingOptions = []) -> String { + Data(self).base64EncodedString(options: options) + } +} diff --git a/SessionUtilitiesKit/Crypto/KeyPair.swift b/SessionUtilitiesKit/Crypto/KeyPair.swift new file mode 100644 index 000000000..14ea83ca0 --- /dev/null +++ b/SessionUtilitiesKit/Crypto/KeyPair.swift @@ -0,0 +1,35 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct KeyPair: Equatable { + public let publicKey: [UInt8] + public let secretKey: [UInt8] + + public var hexEncodedPublicKey: String { + return SessionId(.standard, publicKey: publicKey).hexString + } + + // MARK: - Initialization + + public init(publicKey: [UInt8], secretKey: [UInt8]) { + self.publicKey = publicKey + self.secretKey = secretKey + } + + // MARK: - Functions + + public static func isValidHexEncodedPublicKey(candidate: String) -> Bool { + // Note: If the logic in here changes ensure it doesn't break `SessionId.Prefix(from:)` + // Check that it's a valid hexadecimal encoding + guard Hex.isValid(candidate) else { return false } + + // Check that it has length 66 and a valid prefix + guard candidate.count == 66 && 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 b420a89f7..a02c31940 100644 --- a/SessionUtilitiesKit/Crypto/Mnemonic.swift +++ b/SessionUtilitiesKit/Crypto/Mnemonic.swift @@ -1,9 +1,27 @@ -import CryptoSwift +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation /// Based on [mnemonic.js](https://github.com/loki-project/loki-messenger/blob/development/libloki/modules/mnemonic.js) . public enum Mnemonic { + /// This implementation was sourced from https://gist.github.com/antfarm/695fa78e0730b67eb094c77d53942216 + enum CRC32 { + static let table: [UInt32] = { + (0...255).map { i -> UInt32 in + (0..<8).reduce(UInt32(i), { c, _ in + ((0xEDB88320 * (c % 2)) ^ (c >> 1)) + }) + } + }() + + static func checksum(bytes: [UInt8]) -> UInt32 { + return ~(bytes.reduce(~UInt32(0), { crc, byte in + (crc >> 8) ^ table[(Int(crc) ^ Int(byte)) & 0xFF] + })) + } + } - public struct Language : Hashable { + public struct Language: Hashable { fileprivate let filename: String fileprivate let prefixLength: UInt @@ -12,8 +30,8 @@ public enum Mnemonic { public static let portuguese = Language(filename: "portuguese", prefixLength: 4) public static let spanish = Language(filename: "spanish", prefixLength: 4) - private static var wordSetCache: [Language:[String]] = [:] - private static var truncatedWordSetCache: [Language:[String]] = [:] + private static var wordSetCache: [Language: [String]] = [:] + private static var truncatedWordSetCache: [Language: [String]] = [:] private init(filename: String, prefixLength: UInt) { self.filename = filename @@ -23,23 +41,25 @@ public enum Mnemonic { fileprivate func loadWordSet() -> [String] { if let cachedResult = Language.wordSetCache[self] { return cachedResult - } else { - let url = Bundle.main.url(forResource: filename, withExtension: "txt")! - let contents = try! String(contentsOf: url) - let result = contents.split(separator: ",").map { String($0) } - Language.wordSetCache[self] = result - return result } + + let url = Bundle.main.url(forResource: filename, withExtension: "txt")! + let contents = try! String(contentsOf: url) + let result = contents.split(separator: ",").map { String($0) } + Language.wordSetCache[self] = result + + return result } fileprivate func loadTruncatedWordSet() -> [String] { if let cachedResult = Language.truncatedWordSetCache[self] { return cachedResult - } else { - let result = loadWordSet().map { $0.prefix(length: prefixLength) } - Language.truncatedWordSetCache[self] = result - return result } + + let result = loadWordSet().map { $0.prefix(length: prefixLength) } + Language.truncatedWordSetCache[self] = result + + return result } } @@ -68,6 +88,7 @@ public enum Mnemonic { var result: [String] = [] let n = wordSet.count let characterCount = string.indices.count // Safe for this particular case + for chunkStartIndexAsInt in stride(from: 0, to: characterCount, by: 8) { let chunkStartIndex = string.index(string.startIndex, offsetBy: chunkStartIndexAsInt) let chunkEndIndex = string.index(chunkStartIndex, offsetBy: 8) @@ -76,6 +97,7 @@ public enum Mnemonic { let p3 = string[chunkEndIndex..= 12 else { throw DecodingError.inputTooShort } guard !words.count.isMultiple(of: 3) else { throw DecodingError.missingLastWord } + // Get checksum word let checksumWord = words.popLast()! + // Decode for chunkStartIndex in stride(from: 0, to: words.count, by: 3) { guard let w1 = truncatedWordSet.firstIndex(of: words[chunkStartIndex].prefix(length: prefixLength)), @@ -112,10 +139,12 @@ public enum Mnemonic { let string = "0000000" + String(x, radix: 16) result += swap(String(string[string.index(string.endIndex, offsetBy: -8).. String.Index { return x.index(x.startIndex, offsetBy: indexAsInt) } + let p1 = x[toStringIndex(6).. Int { - let checksum = Array(x.map { $0.prefix(length: prefixLength) }.joined().utf8).crc32() + let checksum = CRC32.checksum(bytes: Array(x.map { $0.prefix(length: prefixLength) }.joined().utf8)) + return Int(checksum) % x.count } } private extension String { - func prefix(length: UInt) -> String { return String(self[startIndex.. String { - return Mnemonic.hash(hexEncodedString: string) - } - - @objc(encodeHexEncodedString:) - public static func encode(hexEncodedString string: String) -> String { - return Mnemonic.encode(hexEncodedString: string) - } -} diff --git a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift index a4df73df3..7d085d68c 100644 --- a/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionUtilitiesKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -2,7 +2,6 @@ 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` diff --git a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift index fca128622..af10c2651 100644 --- a/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionUtilitiesKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -12,7 +12,7 @@ enum _003_YDBToGRDBMigration: Migration { static func migrate(_ db: Database) throws { guard let dbConnection: YapDatabaseConnection = SUKLegacy.newDatabaseConnection() else { - SNLog("[Migration Warning] No legacy database, skipping \(target.key(with: self))") + SNLogNotTests("[Migration Warning] No legacy database, skipping \(target.key(with: self))") return } diff --git a/SessionUtilitiesKit/Database/Migrations/_004_AddJobPriority.swift b/SessionUtilitiesKit/Database/Migrations/_004_AddJobPriority.swift new file mode 100644 index 000000000..384c25195 --- /dev/null +++ b/SessionUtilitiesKit/Database/Migrations/_004_AddJobPriority.swift @@ -0,0 +1,42 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import YapDatabase + +enum _004_AddJobPriority: Migration { + static let target: TargetMigrations.Identifier = .utilitiesKit + static let identifier: String = "AddJobPriority" + static let needsConfigSync: Bool = false + static let minExpectedRunDuration: TimeInterval = 0.1 + + static func migrate(_ db: Database) throws { + // Add `priority` to the job table + try db.alter(table: Job.self) { t in + t.add(.priority, .integer).defaults(to: 0) + } + + // Update the priorities for the below job types (want to ensure they run in the order + // specified to avoid weird bugs) + let variantPriorities: [Int: [Job.Variant]] = [ + 7: [Job.Variant.disappearingMessages], + 6: [Job.Variant.failedMessageSends, Job.Variant.failedAttachmentDownloads], + 5: [Job.Variant.getSnodePool], + 4: [Job.Variant.syncPushTokens], + 3: [Job.Variant.retrieveDefaultOpenGroupRooms], + 2: [Job.Variant.updateProfilePicture], + 1: [Job.Variant.garbageCollection] + ] + + try variantPriorities.forEach { priority, variants in + try Job + .filter(variants.contains(Job.Columns.variant)) + .updateAll( + db, + Job.Columns.priority.set(to: priority) + ) + } + + Storage.update(progress: 1, for: self, in: target) // In case this is the last migration + } +} diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 4f3f82ee8..d7462facb 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -4,7 +4,6 @@ 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" } @@ -39,21 +38,12 @@ public struct Identity: Codable, Identifiable, FetchableRecord, PersistableRecor } } -// 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) { + static func generate(from seed: Data) throws -> (ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) { guard (seed.count == 16) else { throw GeneralError.invalidSeed } + let padding = Data(repeating: 0, count: 16) guard @@ -64,19 +54,24 @@ public extension Identity { throw GeneralError.keyGenerationFailed } - let x25519KeyPair = try ECKeyPair(publicKeyData: Data(x25519PublicKey), privateKeyData: Data(x25519SecretKey)) - - return (ed25519KeyPair: ed25519KeyPair, x25519KeyPair: x25519KeyPair) + return ( + ed25519KeyPair: KeyPair( + publicKey: ed25519KeyPair.publicKey, + secretKey: ed25519KeyPair.secretKey + ), + x25519KeyPair: KeyPair( + publicKey: x25519PublicKey, + secretKey: x25519SecretKey + ) + ) } - 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 store(_ db: Database, seed: Data, ed25519KeyPair: KeyPair, x25519KeyPair: KeyPair) throws { + 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: Data(x25519KeyPair.secretKey)).save(db) + try Identity(variant: .x25519PublicKey, data: Data(x25519KeyPair.publicKey)).save(db) } static func userExists(_ db: Database? = nil) -> Bool { @@ -99,7 +94,7 @@ public extension Identity { return try? Identity.fetchOne(db, id: .x25519PrivateKey)?.data } - static func fetchUserKeyPair(_ db: Database? = nil) -> Box.KeyPair? { + static func fetchUserKeyPair(_ db: Database? = nil) -> KeyPair? { guard let db: Database = db else { return Storage.shared.read { db in fetchUserKeyPair(db) } } @@ -108,13 +103,13 @@ public extension Identity { let privateKey: Data = fetchUserPrivateKey(db) else { return nil } - return Box.KeyPair( + return KeyPair( publicKey: publicKey.bytes, secretKey: privateKey.bytes ) } - static func fetchUserEd25519KeyPair(_ db: Database? = nil) -> Box.KeyPair? { + static func fetchUserEd25519KeyPair(_ db: Database? = nil) -> KeyPair? { guard let db: Database = db else { return Storage.shared.read { db in fetchUserEd25519KeyPair(db) } } @@ -123,7 +118,7 @@ public extension Identity { let secretKey: Data = try? Identity.fetchOne(db, id: .ed25519SecretKey)?.data else { return nil } - return Box.KeyPair( + return KeyPair( publicKey: publicKey.bytes, secretKey: secretKey.bytes ) @@ -153,16 +148,3 @@ public extension Identity { 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 index 737cf9616..800d581cf 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -28,6 +28,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { case id + case priority case failureCount case variant case behaviour @@ -102,10 +103,19 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, /// This is a job that runs once whenever an attachment is downloaded to attempt to decode and properly /// download the attachment case attachmentDownload - + /// This is a job that runs once whenever the user leaves a group to send a group leaving message, remove group /// record and group member record case groupLeaving + + /// This is a job that runs once whenever the user config or a closed group config changes, it retrieves the + /// state of all config objects and syncs any that are flagged as needing to be synced + case configurationSync + + /// This is a job that runs once whenever a config message is received to attempt to decode it and update the + /// config state with the changes; this job will generally be scheduled along since a `messageReceive` job + /// and will block the standard message receive job + case configMessageReceive } public enum Behaviour: Int, Codable, DatabaseValueConvertible, CaseIterable { @@ -132,6 +142,15 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, /// the database yet this value will be `nil` public var id: Int64? = nil + /// The `priority` value is used to allow for forcing some jobs to run before others (Default value `0`) + /// + /// Jobs will be run in the following order: + /// - Jobs scheduled in the past (or with no `nextRunTimestamp`) first + /// - Jobs with a higher `priority` value + /// - Jobs with a sooner `nextRunTimestamp` value + /// - The order the job was inserted into the database + public var priority: Int64 + /// A counter for the number of times this job has failed public let failureCount: UInt @@ -190,6 +209,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, internal init( id: Int64?, + priority: Int64 = 0, failureCount: UInt, variant: Variant, behaviour: Behaviour, @@ -207,6 +227,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, ) self.id = id + self.priority = priority self.failureCount = failureCount self.variant = variant self.behaviour = behaviour @@ -219,6 +240,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, } public init( + priority: Int64 = 0, failureCount: UInt = 0, variant: Variant, behaviour: Behaviour = .runOnce, @@ -234,6 +256,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, shouldSkipLaunchBecomeActive: shouldSkipLaunchBecomeActive ) + self.priority = priority self.failureCount = failureCount self.variant = variant self.behaviour = behaviour @@ -246,6 +269,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, } public init?( + priority: Int64 = 0, failureCount: UInt = 0, variant: Variant, behaviour: Behaviour = .runOnce, @@ -268,6 +292,7 @@ public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, let detailsData: Data = try? JSONEncoder().encode(details) else { return nil } + self.priority = priority self.failureCount = failureCount self.variant = variant self.behaviour = behaviour @@ -328,8 +353,12 @@ extension Job { ) ) .filter(variants.contains(Job.Columns.variant)) - .order(Job.Columns.nextRunTimestamp) - .order(Job.Columns.id) + .order( + Job.Columns.nextRunTimestamp > Date().timeIntervalSince1970, // Past jobs first + Job.Columns.priority.desc, + Job.Columns.nextRunTimestamp, + Job.Columns.id + ) if excludeFutureJobs { query = query.filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970) @@ -352,6 +381,7 @@ public extension Job { ) -> Job { return Job( id: self.id, + priority: self.priority, failureCount: failureCount, variant: self.variant, behaviour: self.behaviour, @@ -369,6 +399,7 @@ public extension Job { return Job( id: self.id, + priority: self.priority, failureCount: self.failureCount, variant: self.variant, behaviour: self.behaviour, diff --git a/SessionUtilitiesKit/Database/Models/JobDependencies.swift b/SessionUtilitiesKit/Database/Models/JobDependencies.swift index 16201367b..bca762c5a 100644 --- a/SessionUtilitiesKit/Database/Models/JobDependencies.swift +++ b/SessionUtilitiesKit/Database/Models/JobDependencies.swift @@ -7,8 +7,8 @@ public struct JobDependencies: Codable, Equatable, Hashable, FetchableRecord, Pe 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 static let job = belongsTo(Job.self, using: jobForeignKey) + public static let dependant = hasOne(Job.self, using: Job.dependencyForeignKey) public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression { diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 21e85f5e1..7f5c3dd13 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -1,31 +1,42 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import CryptoKit import Combine import GRDB -import PromiseKit import SignalCoreKit open class Storage { + public static let queuePrefix: String = "SessionDatabase" private static let dbFileName: String = "Session.sqlite" private static let keychainService: String = "TSKeyChainService" private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec" - private static let kSQLCipherKeySpecLength: Int32 = 48 + private static let kSQLCipherKeySpecLength: Int = 48 + private static let writeWarningThreadshold: TimeInterval = 3 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 hasCreatedValidInstance: Bool { internalHasCreatedValidInstance.wrappedValue } public static var isDatabasePasswordAccessible: Bool { guard (try? getDatabaseCipherKeySpec()) != nil else { return false } return true } + private var startupError: Error? + private let migrationsCompleted: Atomic = Atomic(false) + private static let internalHasCreatedValidInstance: Atomic = Atomic(false) + internal let internalCurrentlyRunningMigration: Atomic<(identifier: TargetMigrations.Identifier, migration: Migration.Type)?> = Atomic(nil) + public static let shared: Storage = Storage() public private(set) var isValid: Bool = false - public private(set) var hasCompletedMigrations: Bool = false + public var hasCompletedMigrations: Bool { migrationsCompleted.wrappedValue } + public var currentlyRunningMigration: (identifier: TargetMigrations.Identifier, migration: Migration.Type)? { + internalCurrentlyRunningMigration.wrappedValue + } public static let defaultPublisherScheduler: ValueObservationScheduler = .async(onQueue: .main) fileprivate var dbWriter: DatabaseWriter? @@ -36,7 +47,14 @@ open class Storage { public init( customWriter: DatabaseWriter? = nil, - customMigrations: [TargetMigrations]? = nil + customMigrationTargets: [MigratableTarget.Type]? = nil + ) { + configureDatabase(customWriter: customWriter, customMigrationTargets: customMigrationTargets) + } + + private func configureDatabase( + customWriter: DatabaseWriter? = nil, + customMigrationTargets: [MigratableTarget.Type]? = 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 @@ -47,7 +65,13 @@ open class Storage { guard customWriter == nil else { dbWriter = customWriter isValid = true - perform(migrations: (customMigrations ?? []), async: false, onProgressUpdate: nil, onComplete: { _, _ in }) + Storage.internalHasCreatedValidInstance.mutate { $0 = true } + perform( + migrationTargets: (customMigrationTargets ?? []), + async: false, + onProgressUpdate: nil, + onComplete: { _, _ in } + ) return } @@ -61,6 +85,7 @@ open class Storage { // Configure the database and create the DatabasePool for interacting with the database var config = Configuration() + config.label = Storage.queuePrefix config.maximumReaderCount = 10 // Increase the max read connection limit - Default is 5 config.observesSuspensionNotifications = true // Minimise `0xDEAD10CC` exceptions config.prepareDatabase { db in @@ -93,40 +118,63 @@ open class Storage { configuration: config ) isValid = true + Storage.internalHasCreatedValidInstance.mutate { $0 = true } } - catch {} + catch { startupError = error } } // MARK: - Migrations + public static func appliedMigrationIdentifiers(_ db: Database) -> Set { + let migrator: DatabaseMigrator = DatabaseMigrator() + + return (try? migrator.appliedIdentifiers(db)) + .defaulting(to: []) + } + public func perform( - migrations: [TargetMigrations], + migrationTargets: [MigratableTarget.Type], async: Bool = true, onProgressUpdate: ((CGFloat, TimeInterval) -> ())?, - onComplete: @escaping (Swift.Result, Bool) -> () + onComplete: @escaping (Swift.Result, Bool) -> () ) { - guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { + let error: Error = (startupError ?? StorageError.startupFailed) + SNLog("[Database Error] Statup failed with error: \(error)") + onComplete(.failure(StorageError.startupFailed), false) + 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([]) - } + let maybeSortedMigrationInfo: [MigrationInfo]? = try? dbWriter + .read { db -> [MigrationInfo] in + migrationTargets + .map { target -> TargetMigrations in target.migrations(db) } + .sorted() + .reduce(into: [[MigrationInfo]]()) { result, next in + next.migrations.enumerated().forEach { index, migrationSet in + if result.count <= index { + result.append([]) + } - result[index] = (result[index] + [(next.identifier, migrationSet)]) - } + result[index] = (result[index] + [(next.identifier, migrationSet)]) + } + } + .reduce(into: []) { result, next in result.append(contentsOf: next) } } - .reduce(into: []) { result, next in result.append(contentsOf: next) } + + guard let sortedMigrationInfo: [MigrationInfo] = maybeSortedMigrationInfo else { + SNLog("[Database Error] Statup failed with error: Unable to prepare migrations") + onComplete(.failure(StorageError.startupFailed), false) + return + } // Setup and run any required migrations - migrator = { + migrator = { [weak self] in var migrator: DatabaseMigrator = DatabaseMigrator() sortedMigrationInfo.forEach { migrationInfo in migrationInfo.migrations.forEach { migration in - migrator.registerMigration(migrationInfo.identifier, migration: migration) + migrator.registerMigration(self, targetIdentifier: migrationInfo.identifier, migration: migration) } } @@ -173,37 +221,58 @@ open class Storage { } }) - // If we have an unperformed migration then trigger the progress updater immediately - if let firstMigrationKey: String = unperformedMigrations.first?.key { - self.migrationProgressUpdater?.wrappedValue(firstMigrationKey, 0) - } - // Store the logic to run when the migration completes - let migrationCompleted: (Swift.Result) -> () = { [weak self] result in - self?.hasCompletedMigrations = true + let migrationCompleted: (Swift.Result) -> () = { [weak self] result in + self?.migrationsCompleted.mutate { $0 = true } self?.migrationProgressUpdater = nil SUKLegacy.clearLegacyDatabaseInstance() - if case .failure(let error) = result { - SNLog("[Migration Error] Migration failed with error: \(error)") + // Don't log anything in the case of a 'success' or if the database is suspended (the + // latter will happen if the user happens to return to the background too quickly on + // launch so is unnecessarily alarming, it also gets caught and logged separately by + // the 'write' functions anyway) + switch result { + case .success: break + case .failure(DatabaseError.SQLITE_ABORT): break + case .failure(let error): SNLog("[Migration Error] Migration failed with error: \(error)") } onComplete(result, needsConfigSync) } + // if there aren't any migrations to run then just complete immediately (this way the migrator + // doesn't try to execute on the DBWrite thread so returning from the background can't get blocked + // due to some weird endless process running) + guard !unperformedMigrations.isEmpty else { + migrationCompleted(.success(())) + return + } + + // If we have an unperformed migration then trigger the progress updater immediately + if let firstMigrationKey: String = unperformedMigrations.first?.key { + self.migrationProgressUpdater?.wrappedValue(firstMigrationKey, 0) + } + // 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(Swift.Result.failure(error)) - } - } + catch { migrationCompleted(Swift.Result.failure(error)) } return } self.migrator?.asyncMigrate(dbWriter) { result in - migrationCompleted(result) + let finalResult: Swift.Result = { + switch result { + case .failure(let error): return .failure(error) + case .success: return .success(()) + } + }() + + // Note: We need to dispatch this to the next run toop to prevent any potential re-entrancy + // issues since the 'asyncMigrate' returns a result containing a DB instance + DispatchQueue.global(qos: .userInitiated).async { + migrationCompleted(finalResult) + } } } @@ -248,7 +317,7 @@ open class Storage { case (_, errSecItemNotFound): // No keySpec was found so we need to generate a new one do { - var keySpec: Data = Randomness.generateRandomBytes(kSQLCipherKeySpecLength) + var keySpec: Data = try Randomness.generateRandomBytes(numberBytes: kSQLCipherKeySpecLength) defer { keySpec.resetBytes(in: 0..( + info: CallInfo, + updates: @escaping (Database) throws -> T + ) -> (Database) throws -> T { + return { db in + let start: CFTimeInterval = CACurrentMediaTime() + let fileName: String = (info.file.components(separatedBy: "/").last.map { " \($0):\(info.line)" } ?? "") + let timeout: Timer = Timer.scheduledTimerOnMainThread(withTimeInterval: writeWarningThreadshold) { + $0.invalidate() + + // Don't want to log on the main thread as to avoid confusion when debugging issues + DispatchQueue.global(qos: .default).async { + SNLog("[Storage\(fileName)] Slow write taking longer than \(writeWarningThreadshold, format: ".2", omitZeroDecimal: true)s - \(info.function)") + } + } + defer { + // If we timed out then log the actual duration to help us prioritise performance issues + if !timeout.isValid { + let end: CFTimeInterval = CACurrentMediaTime() + + DispatchQueue.global(qos: .default).async { + SNLog("[Storage\(fileName)] Slow write completed after \(end - start, format: ".2", omitZeroDecimal: true)s") + } + } + + timeout.invalidate() + } + + return try updates(db) + } + } + + private static func logIfNeeded(_ error: Error, isWrite: Bool) { + switch error { + case DatabaseError.SQLITE_ABORT: + let message: String = ((error as? DatabaseError)?.message ?? "Unknown") + SNLog("[Storage] Database \(isWrite ? "write" : "read") failed due to error: \(message)") + + default: break + } + } + + private static func logIfNeeded(_ error: Error, isWrite: Bool) -> T? { + logIfNeeded(error, isWrite: isWrite) + return nil + } + // MARK: - Functions - @discardableResult public final func write(updates: (Database) throws -> T?) -> T? { + @discardableResult public final func write( + fileName: String = #file, + functionName: String = #function, + lineNumber: Int = #line, + updates: @escaping (Database) throws -> T? + ) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } - return try? dbWriter.write(updates) + let info: CallInfo = (fileName, functionName, lineNumber) + + do { return try dbWriter.write(Storage.logSlowWrites(info: info, updates: updates)) } + catch { return Storage.logIfNeeded(error, isWrite: true) } } - open func writeAsync(updates: @escaping (Database) throws -> T) { - writeAsync(updates: updates, completion: { _, _ in }) + open func writeAsync( + fileName: String = #file, + functionName: String = #function, + lineNumber: Int = #line, + updates: @escaping (Database) throws -> T + ) { + writeAsync( + fileName: fileName, + functionName: functionName, + lineNumber: lineNumber, + updates: updates, + completion: { _, _ in } + ) } - open func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { + open func writeAsync( + fileName: String = #file, + functionName: String = #function, + lineNumber: Int = #line, + updates: @escaping (Database) throws -> T, + completion: @escaping (Database, Swift.Result) throws -> Void + ) { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return } + let info: CallInfo = (fileName, functionName, lineNumber) + dbWriter.asyncWrite( - updates, + Storage.logSlowWrites(info: info, updates: updates), completion: { db, result in + switch result { + case .failure(let error): Storage.logIfNeeded(error, isWrite: true) + default: break + } + try? completion(db, result) } ) } + open func writePublisher( + fileName: String = #file, + functionName: String = #function, + lineNumber: Int = #line, + updates: @escaping (Database) throws -> T + ) -> AnyPublisher { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { + return Fail(error: StorageError.databaseInvalid) + .eraseToAnyPublisher() + } + + let info: CallInfo = (fileName, functionName, lineNumber) + + /// **Note:** GRDB does have a `writePublisher` method but it appears to asynchronously trigger + /// both the `output` and `complete` closures at the same time which causes a lot of unexpected + /// behaviours (this behaviour is apparently expected but still causes a number of odd behaviours in our code + /// for more information see https://github.com/groue/GRDB.swift/issues/1334) + /// + /// Instead of this we are just using `Deferred { Future {} }` which is executed on the specified scheduled + /// which behaves in a much more expected way than the GRDB `writePublisher` does + return Deferred { + Future { resolver in + do { resolver(Result.success(try dbWriter.write(Storage.logSlowWrites(info: info, updates: updates)))) } + catch { + Storage.logIfNeeded(error, isWrite: true) + resolver(Result.failure(error)) + } + } + }.eraseToAnyPublisher() + } + + open func readPublisher( + value: @escaping (Database) throws -> T + ) -> AnyPublisher { + guard isValid, let dbWriter: DatabaseWriter = dbWriter else { + return Fail(error: StorageError.databaseInvalid) + .eraseToAnyPublisher() + } + + /// **Note:** GRDB does have a `readPublisher` method but it appears to asynchronously trigger + /// both the `output` and `complete` closures at the same time which causes a lot of unexpected + /// behaviours (this behaviour is apparently expected but still causes a number of odd behaviours in our code + /// for more information see https://github.com/groue/GRDB.swift/issues/1334) + /// + /// Instead of this we are just using `Deferred { Future {} }` which is executed on the specified scheduled + /// which behaves in a much more expected way than the GRDB `readPublisher` does + return Deferred { + Future { resolver in + do { resolver(Result.success(try dbWriter.read(value))) } + catch { + Storage.logIfNeeded(error, isWrite: false) + resolver(Result.failure(error)) + } + } + }.eraseToAnyPublisher() + } + @discardableResult public final func read(_ value: (Database) throws -> T?) -> T? { guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil } - return try? dbWriter.read(value) + do { return try dbWriter.read(value) } + catch { return Storage.logIfNeeded(error, isWrite: false) } } /// Rever to the `ValueObservation.start` method for full documentation @@ -385,50 +604,6 @@ open class Storage { } } -// 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) - } - } - - // FIXME: Can't overrwrite this in `SynchronousStorage` since it's in an extension - @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 - } -} - // MARK: - Combine Extensions public extension ValueObservation { @@ -444,3 +619,38 @@ public extension ValueObservation { .eraseToAnyPublisher() } } + +// MARK: - Debug Convenience + +#if DEBUG +public extension Storage { + func exportInfo(password: String) throws -> (dbPath: String, keyPath: String) { + var keySpec: Data = try Storage.getOrGenerateDatabaseKeySpec() + defer { keySpec.resetBytes(in: 0.. ((_ db: Database) throws -> ()) { + static func loggedMigrate( + _ storage: Storage?, + targetIdentifier: TargetMigrations.Identifier + ) -> ((_ db: Database) throws -> ()) { return { (db: Database) in - SNLog("[Migration Info] Starting \(targetIdentifier.key(with: self))") + SNLogNotTests("[Migration Info] Starting \(targetIdentifier.key(with: self))") + storage?.internalCurrentlyRunningMigration.mutate { $0 = (targetIdentifier, self) } + defer { storage?.internalCurrentlyRunningMigration.mutate { $0 = nil } } + try migrate(db) - SNLog("[Migration Info] Completed \(targetIdentifier.key(with: self))") + SNLogNotTests("[Migration Info] Completed \(targetIdentifier.key(with: self))") } } } diff --git a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift index 88ecd76cc..ab6ae915f 100644 --- a/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift +++ b/SessionUtilitiesKit/Database/Types/PagedDatabaseObserver.swift @@ -10,6 +10,12 @@ import DifferenceKit /// /// **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 { + private let commitProcessingQueue: DispatchQueue = DispatchQueue( + label: "PagedDatabaseObserver.commitProcessingQueue", + qos: .userInitiated, + attributes: [] // Must be serial in order to avoid updates getting processed in the wrong order + ) + // MARK: - Variables private let pagedTableName: String @@ -26,7 +32,7 @@ public class PagedDatabaseObserver: TransactionObserver where private let filterSQL: SQL private let groupSQL: SQL? private let orderSQL: SQL - private let dataQuery: ([Int64]) -> AdaptedFetchRequest> + private let dataQuery: ([Int64]) -> any FetchRequest private let associatedRecords: [ErasedAssociatedRecord] private var dataCache: Atomic> = Atomic(DataCache()) @@ -45,7 +51,7 @@ public class PagedDatabaseObserver: TransactionObserver where filterSQL: SQL, groupSQL: SQL? = nil, orderSQL: SQL, - dataQuery: @escaping ([Int64]) -> AdaptedFetchRequest>, + dataQuery: @escaping ([Int64]) -> any FetchRequest, associatedRecords: [ErasedAssociatedRecord] = [], onChangeUnsorted: @escaping ([T], PagedData.PageInfo) -> () ) { @@ -145,74 +151,58 @@ public class PagedDatabaseObserver: TransactionObserver where changesInCommit.mutate { $0.insert(trackedChange) } } - // 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...) + /// 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...) + /// + /// **Note:** This function is generally called within the DBWrite thread but we don't actually need write access to process the commit, in order + /// to avoid blocking the DBWrite thread we dispatch to a serial `commitProcessingQueue` to process the incoming changes (in the past not doing + /// so was resulting in hanging when there was a lot of activity happening) public func databaseDidCommit(_ db: Database) { + // If there were no pending changes in the commit then do nothing + guard !self.changesInCommit.wrappedValue.isEmpty else { return } + + // Since we can't be sure the behaviours of 'databaseDidChange' and 'databaseDidCommit' won't change in + // the future we extract and clear the values in 'changesInCommit' since it's 'Atomic' so will different + // threads modifying the data resulting in us missing a change 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 } + commitProcessingQueue.async { [weak self] in + self?.processDatabaseCommit(committedChanges: committedChanges) + } + } + + private func processDatabaseCommit(committedChanges: Set) { + typealias AssociatedDataInfo = [(hasChanges: Bool, data: ErasedAssociatedRecord)] + typealias UpdatedData = (cache: DataCache, pageInfo: PagedData.PageInfo, hasChanges: Bool, associatedData: AssociatedDataInfo) + // Store the instance variables locally to avoid unwrapping + let dataCache: DataCache = self.dataCache.wrappedValue + let pageInfo: PagedData.PageInfo = self.pageInfo.wrappedValue 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 } + let getAssociatedDataInfo: (Database, PagedData.PageInfo) -> AssociatedDataInfo = { db, updatedPageInfo in + associatedRecords.map { associatedRecord in + let hasChanges: Bool = associatedRecord.tryUpdateForDatabaseCommit( + db, + changes: committedChanges, + joinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL, + pageInfo: updatedPageInfo + ) - finalUpdatedDataCache = associatedData.updateAssociatedData(to: finalUpdatedDataCache) + return (hasChanges, associatedRecord) } - - // Update the cache, pageInfo and the change callback - self?.dataCache.mutate { $0 = finalUpdatedDataCache } - self?.pageInfo.mutate { $0 = updatedPageInfo } - - - // Make sure the updates run on the main thread - guard Thread.isMainThread else { - DispatchQueue.main.async { [weak self] in - self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo) - } - return - } - - self?.onChangeUnsorted(finalUpdatedDataCache.values, updatedPageInfo) } - // Determing if there were any direct or related data changes + // Determine if there were any direct or related data changes let directChanges: Set = committedChanges .filter { $0.tableName == pagedTableName } let relatedChanges: [String: [PagedData.TrackedChange]] = committedChanges @@ -227,215 +217,248 @@ public class PagedDatabaseObserver: TransactionObserver where .filter { $0.tableName != pagedTableName } .filter { $0.kind == .delete } - guard !directChanges.isEmpty || !relatedChanges.isEmpty || !relatedDeletions.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 || !relatedDeletions.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) + // Process and retrieve the updated data + let updatedData: UpdatedData = Storage.shared + .read { db -> UpdatedData in + // If there aren't any direct or related changes then early-out + guard !directChanges.isEmpty || !relatedChanges.isEmpty || !relatedDeletions.isEmpty else { + return (dataCache, pageInfo, false, getAssociatedDataInfo(db, pageInfo)) } - .asSet() - }() - - guard !changesToQuery.isEmpty || !pagedRowIdsForRelatedChanges.isEmpty || !relatedDeletions.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 directRowIds: Set = changesToQuery.map { $0.rowId }.asSet() - let pagedRowIdsForRelatedDeletions: Set = relatedDeletions - .compactMap { $0.pagedRowIdsForRelatedDeletion } - .flatMap { $0 } - .asSet() - let itemIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( - db, - rowIds: Array(directRowIds), - 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 - ) - let relatedDeletionIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( - db, - rowIds: Array(pagedRowIdsForRelatedDeletions), - 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) - ) + + // Store a mutable copies of the dataCache and pageInfo for updating + var updatedDataCache: DataCache = dataCache + var updatedPageInfo: PagedData.PageInfo = pageInfo + let deletionChanges: [Int64] = directChanges + .filter { $0.kind == .delete } + .map { $0.rowId } + let oldDataCount: Int = dataCache.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 then early-out + let changesToQuery: [PagedData.TrackedChange] = directChanges + .filter { $0.kind != .delete } + + guard !changesToQuery.isEmpty || !relatedChanges.isEmpty || !relatedDeletions.isEmpty else { + let associatedData: AssociatedDataInfo = getAssociatedDataInfo(db, updatedPageInfo) + return (updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty, associatedData) + } + + // Next we need to determine if any related changes were associated to the pagedData we are + // observing, if they aren't (and there were no other direct changes) we can early-out + 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 || !relatedDeletions.isEmpty else { + let associatedData: AssociatedDataInfo = getAssociatedDataInfo(db, updatedPageInfo) + return (updatedDataCache, updatedPageInfo, !deletionChanges.isEmpty, associatedData) + } + + // Fetch the indexes of the rowIds so we can determine whether they should be added to the screen + let directRowIds: Set = changesToQuery.map { $0.rowId }.asSet() + let pagedRowIdsForRelatedDeletions: Set = relatedDeletions + .compactMap { $0.pagedRowIdsForRelatedDeletion } + .flatMap { $0 } + .asSet() + let itemIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( + db, + rowIds: Array(directRowIds), + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL ) - }) - - return (indexesAreSequential && hasOneValidIndex ? - indexInfo.map { $0.rowId } : - indexInfo - .filter { info -> Bool in + let relatedChangeIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( + db, + rowIds: Array(pagedRowIdsForRelatedChanges), + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + orderSQL: orderSQL, + filterSQL: filterSQL + ) + let relatedDeletionIndexes: [PagedData.RowIndexInfo] = PagedData.indexes( + db, + rowIds: Array(pagedRowIdsForRelatedDeletions), + 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 validRelatedDeletionRowIds: [Int64] = determineValidChanges(for: relatedDeletionIndexes) + let countBefore: Int = itemIndexes.filter { $0.rowIndex < updatedPageInfo.pageOffset }.count + + // If the number of indexes doesn't match the number of rowIds then it means something changed + // resulting in an item being filtered out + func performRemovalsIfNeeded(for rowIds: Set, indexes: [PagedData.RowIndexInfo]) { + let uniqueIndexes: Set = indexes.map { $0.rowId }.asSet() + + // If they have the same count then nothin was filtered out so do nothing + guard rowIds.count != uniqueIndexes.count else { return } + + // Otherwise something was probably removed so try to remove it from the cache + let rowIdsRemoved: Set = rowIds.subtracting(uniqueIndexes) + let preDeletionCount: Int = updatedDataCache.count + updatedDataCache = updatedDataCache.deleting(rowIds: Array(rowIdsRemoved)) + + // Lastly make sure there were actually changes before updating the page info + guard updatedDataCache.count != preDeletionCount else { return } + + let dataSizeDiff: Int = (updatedDataCache.count - preDeletionCount) + + updatedPageInfo = PagedData.PageInfo( + pageSize: updatedPageInfo.pageSize, + pageOffset: updatedPageInfo.pageOffset, + currentCount: (updatedPageInfo.currentCount + dataSizeDiff), + totalCount: (updatedPageInfo.totalCount + dataSizeDiff) + ) + } + + // Actually perform any required removals + performRemovalsIfNeeded(for: directRowIds, indexes: itemIndexes) + performRemovalsIfNeeded(for: pagedRowIdsForRelatedChanges, indexes: relatedChangeIndexes) + performRemovalsIfNeeded(for: pagedRowIdsForRelatedDeletions, indexes: relatedDeletionIndexes) + + // 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 early-out (at this point the pageInfo would have changed + // so we want to flat 'hasChanges' as true) + guard !validChangeRowIds.isEmpty || !validRelatedChangeRowIds.isEmpty || !validRelatedDeletionRowIds.isEmpty else { + let associatedData: AssociatedDataInfo = getAssociatedDataInfo(db, updatedPageInfo) + return (updatedDataCache, updatedPageInfo, true, associatedData) + } + + // Fetch the inserted/updated rows + let targetRowIds: [Int64] = Array((validChangeRowIds + validRelatedChangeRowIds + validRelatedDeletionRowIds).asSet()) + let updatedItems: [T] = { + do { return try dataQuery(targetRowIds).fetchAll(db) } + catch { + SNLog("[PagedDatabaseObserver] Error fetching data during change: \(error)") + return [] } - .map { info -> Int64 in info.rowId } - ) - } - let validChangeRowIds: [Int64] = determineValidChanges(for: itemIndexes) - let validRelatedChangeRowIds: [Int64] = determineValidChanges(for: relatedChangeIndexes) - let validRelatedDeletionRowIds: [Int64] = determineValidChanges(for: relatedDeletionIndexes) - let countBefore: Int = itemIndexes.filter { $0.rowIndex < updatedPageInfo.pageOffset }.count + }() + + 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 + ) + + // Return the final updated data + let associatedData: AssociatedDataInfo = getAssociatedDataInfo(db, updatedPageInfo) + return (updatedDataCache, updatedPageInfo, true, associatedData) + } + .defaulting(to: (cache: dataCache, pageInfo: pageInfo, hasChanges: false, associatedData: [])) - // If the number of indexes doesn't match the number of rowIds then it means something changed - // resulting in an item being filtered out - func performRemovalsIfNeeded(for rowIds: Set, indexes: [PagedData.RowIndexInfo]) { - let uniqueIndexes: Set = indexes.map { $0.rowId }.asSet() - - // If they have the same count then nothin was filtered out so do nothing - guard rowIds.count != uniqueIndexes.count else { return } - - // Otherwise something was probably removed so try to remove it from the cache - let rowIdsRemoved: Set = rowIds.subtracting(uniqueIndexes) - let preDeletionCount: Int = updatedDataCache.count - updatedDataCache = updatedDataCache.deleting(rowIds: Array(rowIdsRemoved)) - - // Lastly make sure there were actually changes before updating the page info - guard updatedDataCache.count != preDeletionCount else { return } - - let dataSizeDiff: Int = (updatedDataCache.count - preDeletionCount) - - updatedPageInfo = PagedData.PageInfo( - pageSize: updatedPageInfo.pageSize, - pageOffset: updatedPageInfo.pageOffset, - currentCount: (updatedPageInfo.currentCount + dataSizeDiff), - totalCount: (updatedPageInfo.totalCount + dataSizeDiff) - ) - } - - // Actually perform any required removals - performRemovalsIfNeeded(for: directRowIds, indexes: itemIndexes) - performRemovalsIfNeeded(for: pagedRowIdsForRelatedChanges, indexes: relatedChangeIndexes) - performRemovalsIfNeeded(for: pagedRowIdsForRelatedDeletions, indexes: relatedDeletionIndexes) - - // 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 || !validRelatedDeletionRowIds.isEmpty else { - updateDataAndCallbackIfNeeded(updatedDataCache, updatedPageInfo, true) + // Now that we have all of the changes, check if there were actually any changes + guard updatedData.hasChanges || updatedData.associatedData.contains(where: { hasChanges, _ in hasChanges }) else { return } - // Fetch the inserted/updated rows - let targetRowIds: [Int64] = Array((validChangeRowIds + validRelatedChangeRowIds + validRelatedDeletionRowIds).asSet()) - let updatedItems: [T] = (try? dataQuery(targetRowIds) - .fetchAll(db)) - .defaulting(to: []) + // If the associated data changed then update the updatedCachedData with the updated associated data + var finalUpdatedDataCache: DataCache = updatedData.cache - // 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) + updatedData.associatedData.forEach { hasChanges, associatedData in + guard updatedData.hasChanges || hasChanges else { return } + + finalUpdatedDataCache = associatedData.updateAssociatedData(to: finalUpdatedDataCache) + } + + // Update the cache, pageInfo and the change callback + self.dataCache.mutate { $0 = finalUpdatedDataCache } + self.pageInfo.mutate { $0 = updatedData.pageInfo } + + // Trigger the unsorted change callback (the actual UI update triggering should eventually be run on + // the main thread via the `PagedData.processAndTriggerUpdates` function) + self.onChangeUnsorted(finalUpdatedDataCache.values, updatedData.pageInfo) } public func databaseDidRollback(_ db: Database) {} @@ -463,7 +486,7 @@ public class PagedDatabaseObserver: TransactionObserver where let filterSQL: SQL = self.filterSQL let groupSQL: SQL? = self.groupSQL let orderSQL: SQL = self.orderSQL - let dataQuery: ([Int64]) -> AdaptedFetchRequest> = self.dataQuery + let dataQuery: ([Int64]) -> any FetchRequest = self.dataQuery let loadedPage: (data: [T]?, pageInfo: PagedData.PageInfo, failureCallback: (() -> ())?)? = Storage.shared.read { [weak self] db in typealias QueryInfo = (limit: Int, offset: Int, updatedCacheOffset: Int) @@ -663,49 +686,53 @@ public class PagedDatabaseObserver: TransactionObserver where } // 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 pageRowIds: [Int64] let newData: [T] + let updatedLimitInfo: PagedData.PageInfo - do { newData = try dataQuery(pageRowIds).fetchAll(db) } - catch { - SNLog("PagedDatabaseObserver threw exception: \(error)") - throw error - } - - let updatedLimitInfo: PagedData.PageInfo = PagedData.PageInfo( - pageSize: currentPageInfo.pageSize, - pageOffset: queryInfo.updatedCacheOffset, - currentCount: { - switch target { - case .reloadCurrent: return currentPageInfo.currentCount - default: return (currentPageInfo.currentCount + newData.count) - } - }(), - totalCount: totalCount - ) - - // Update the associatedRecords for the newly retrieved data - self?.associatedRecords.forEach { record in - record.updateCache( + do { + pageRowIds = try PagedData.rowIds( db, - rowIds: PagedData.associatedRowIds( - db, - tableName: record.databaseTableName, - pagedTableName: pagedTableName, - pagedTypeRowIds: newData.map { $0.rowId }, - joinToPagedType: record.joinToPagedType - ), - hasOtherChanges: false + tableName: pagedTableName, + requiredJoinSQL: joinSQL, + filterSQL: filterSQL, + groupSQL: groupSQL, + orderSQL: orderSQL, + limit: queryInfo.limit, + offset: queryInfo.offset ) + newData = try dataQuery(pageRowIds).fetchAll(db) + updatedLimitInfo = PagedData.PageInfo( + pageSize: currentPageInfo.pageSize, + pageOffset: queryInfo.updatedCacheOffset, + currentCount: { + switch target { + case .reloadCurrent: return currentPageInfo.currentCount + default: return (currentPageInfo.currentCount + newData.count) + } + }(), + totalCount: totalCount + ) + + // Update the associatedRecords for the newly retrieved data + let newDataRowIds: [Int64] = newData.map { $0.rowId } + try self?.associatedRecords.forEach { record in + record.updateCache( + db, + rowIds: try PagedData.associatedRowIds( + db, + tableName: record.databaseTableName, + pagedTableName: pagedTableName, + pagedTypeRowIds: newDataRowIds, + joinToPagedType: record.joinToPagedType + ), + hasOtherChanges: false + ) + } + } + catch { + SNLog("[PagedDatabaseObserver] Error loading data: \(error)") + throw error } return (newData, updatedLimitInfo, nil) @@ -759,34 +786,6 @@ public class PagedDatabaseObserver: TransactionObserver where // 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) } @@ -1041,18 +1040,20 @@ public enum PagedData { target: updatedData ) - // No need to do anything if there were no changes - guard !changeset.isEmpty else { return } - - // If we have the 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) + /// If we have the 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) + /// + /// **Note:** We do this even if the 'changeset' is empty because if this change reverts a previous change we + /// need to ensure the `onUnobservedDataChange` gets cleared so it doesn't end up in an invalid state guard let onDataChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ()) = onDataChange else { onUnobservedDataChange(updatedData, changeset) return } + // No need to do anything if there were no changes + guard !changeset.isEmpty else { return } + onDataChange(updatedData, changeset) } @@ -1098,7 +1099,7 @@ public enum PagedData { orderSQL: SQL, limit: Int, offset: Int - ) -> [Int64] { + ) throws -> [Int64] { let tableNameLiteral: SQL = SQL(stringLiteral: tableName) let finalJoinSQL: SQL = (requiredJoinSQL ?? "") let finalGroupSQL: SQL = (groupSQL ?? "") @@ -1112,8 +1113,7 @@ public enum PagedData { LIMIT \(limit) OFFSET \(offset) """ - return (try? request.fetchAll(db)) - .defaulting(to: []) + return try request.fetchAll(db) } fileprivate static func index( @@ -1186,7 +1186,7 @@ public enum PagedData { pagedTableName: String, pagedTypeRowIds: [Int64], joinToPagedType: SQL - ) -> [Int64] { + ) throws -> [Int64] { guard !pagedTypeRowIds.isEmpty else { return [] } let tableNameLiteral: SQL = SQL(stringLiteral: tableName) @@ -1198,8 +1198,7 @@ public enum PagedData { WHERE \(pagedTableNameLiteral).rowId IN \(pagedTypeRowIds) """ - return (try? request.fetchAll(db)) - .defaulting(to: []) + return try request.fetchAll(db) } /// Returns the rowIds for the paged type based on the specified relatedRowIds @@ -1235,7 +1234,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet public let joinToPagedType: SQL fileprivate let dataCache: Atomic> = Atomic(DataCache()) - fileprivate let dataQuery: (SQL?) -> AdaptedFetchRequest> + fileprivate let dataQuery: (SQL?) -> any FetchRequest fileprivate let associateData: (DataCache, DataCache) -> DataCache // MARK: - Initialization @@ -1243,7 +1242,7 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet public init( trackedAgainst: Table.Type, observedChanges: [PagedData.ObservedChanges], - dataQuery: @escaping (SQL?) -> AdaptedFetchRequest>, + dataQuery: @escaping (SQL?) -> any FetchRequest, joinToPagedType: SQL, associateData: @escaping (DataCache, DataCache) -> DataCache ) { @@ -1254,24 +1253,6 @@ public class AssociatedRecord: ErasedAssociatedRecord where T: Fet 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 { diff --git a/SessionUtilitiesKit/Database/Types/TargetMigrations.swift b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift index ad305ab73..f9627a7e8 100644 --- a/SessionUtilitiesKit/Database/Types/TargetMigrations.swift +++ b/SessionUtilitiesKit/Database/Types/TargetMigrations.swift @@ -1,6 +1,11 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import GRDB + +public protocol MigratableTarget { + static func migrations(_ db: Database) -> TargetMigrations +} public struct TargetMigrations: Comparable { /// This identifier is used to determine the order each set of migrations should run in. diff --git a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift index e90b90909..53c30f9c6 100644 --- a/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift @@ -27,6 +27,33 @@ public extension Database { } } + func drop(table: T.Type) throws where T: TableRecord { + try drop(table: T.databaseTableName) + } + + func createIndex( + withCustomName customName: String? = nil, + on table: T.Type, + columns: [T.Columns], + options: IndexOptions = [], + condition: (any SQLExpressible)? = nil + ) throws where T: TableRecord, T: ColumnExpressible { + guard !columns.isEmpty else { throw StorageError.invalidData } + + let indexName: String = ( + customName ?? + "\(T.databaseTableName)_on_\(columns.map { $0.name }.joined(separator: "_and_"))" + ) + + try create( + index: indexName, + on: T.databaseTableName, + columns: columns.map { $0.name }, + options: options, + condition: condition + ) + } + func makeFTS5Pattern(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible { return try makeFTS5Pattern(rawPattern: rawPattern, forTable: table.databaseTableName) } @@ -126,4 +153,3 @@ fileprivate class TransactionHandler: TransactionObserver { } } } - diff --git a/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift index 337dd805f..179a3edd3 100644 --- a/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/DatabaseMigrator+Utilities.swift @@ -4,10 +4,15 @@ import Foundation import GRDB public extension DatabaseMigrator { - mutating func registerMigration(_ targetIdentifier: TargetMigrations.Identifier, migration: Migration.Type, foreignKeyChecks: ForeignKeyChecks = .deferred) { + mutating func registerMigration( + _ storage: Storage?, + targetIdentifier: TargetMigrations.Identifier, + migration: Migration.Type, + foreignKeyChecks: ForeignKeyChecks = .deferred + ) { self.registerMigration( targetIdentifier.key(with: migration), - migrate: migration.loggedMigrate(targetIdentifier) + migrate: migration.loggedMigrate(storage, targetIdentifier: targetIdentifier) ) } } diff --git a/SessionUtilitiesKit/Database/Utilities/FetchRequest+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/FetchRequest+Utilities.swift new file mode 100644 index 000000000..a3bccd855 --- /dev/null +++ b/SessionUtilitiesKit/Database/Utilities/FetchRequest+Utilities.swift @@ -0,0 +1,11 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import GRDB + +public extension FetchRequest where RowDecoder: DatabaseValueConvertible { + func fetchOne(_ db: Database, orThrow error: Error) throws -> RowDecoder { + guard let result: RowDecoder = try fetchOne(db) else { throw error } + + return result + } +} diff --git a/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift b/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift index fa9419022..69ce03db5 100644 --- a/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift +++ b/SessionUtilitiesKit/Database/Utilities/QueryInterfaceRequest+Utilities.swift @@ -10,7 +10,7 @@ public extension QueryInterfaceRequest { /// /// - parameter db: A database connection. /// - returns: Whether the request matches a row in the database. - func isNotEmpty(_ db: Database) throws -> Bool { + func isNotEmpty(_ db: Database) -> Bool { return ((try? SQLRequest("SELECT \(exists())").fetchOne(db)) ?? false) } } diff --git a/SessionUtilitiesKit/General/Collection+Subscripting.swift b/SessionUtilitiesKit/General/Collection+Subscripting.swift deleted file mode 100644 index 70ffcf61d..000000000 --- a/SessionUtilitiesKit/General/Collection+Subscripting.swift +++ /dev/null @@ -1,7 +0,0 @@ - -extension Collection { - - public subscript(ifValid index: Index) -> Iterator.Element? { - return self.indices.contains(index) ? self[index] : nil - } -} diff --git a/SessionUtilitiesKit/General/Collection+Utilities.swift b/SessionUtilitiesKit/General/Collection+Utilities.swift new file mode 100644 index 000000000..0422552df --- /dev/null +++ b/SessionUtilitiesKit/General/Collection+Utilities.swift @@ -0,0 +1,45 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +extension Collection { + public subscript(ifValid index: Index) -> Iterator.Element? { + return self.indices.contains(index) ? self[index] : nil + } +} + +public extension Collection { + /// This creates an UnsafeMutableBufferPointer to access data in memory directly. This result pointer provides no automated + /// memory management so after use you are responsible for handling the life cycle and need to call `deallocate()`. + func unsafeCopy() -> UnsafeMutableBufferPointer { + let copy = UnsafeMutableBufferPointer.allocate(capacity: self.underestimatedCount) + _ = copy.initialize(from: self) + return copy + } +} + +public extension Collection where Element == [CChar] { + /// This creates an array of UnsafePointer types to access data of the C strings in memory. This array provides no automated + /// memory management of it's children so after use you are responsible for handling the life cycle of the child elements and + /// need to call `deallocate()` on each child. + func unsafeCopy() -> [UnsafePointer?] { + return self.map { value in + let copy = UnsafeMutableBufferPointer.allocate(capacity: value.count) + _ = copy.initialize(from: value) + return UnsafePointer(copy.baseAddress) + } + } +} + +public extension Collection where Element == [UInt8] { + /// This creates an array of UnsafePointer types to access data of the C strings in memory. This array provides no automated + /// memory management of it's children so after use you are responsible for handling the life cycle of the child elements and + /// need to call `deallocate()` on each child. + func unsafeCopy() -> [UnsafePointer?] { + return self.map { value in + let copy = UnsafeMutableBufferPointer.allocate(capacity: value.count) + _ = copy.initialize(from: value) + return UnsafePointer(copy.baseAddress) + } + } +} diff --git a/SessionUtilitiesKit/General/Data+Utilities.swift b/SessionUtilitiesKit/General/Data+Utilities.swift index 7db8c81da..bac5db7d9 100644 --- a/SessionUtilitiesKit/General/Data+Utilities.swift +++ b/SessionUtilitiesKit/General/Data+Utilities.swift @@ -2,7 +2,22 @@ import Foundation +public 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 HTTPError.parsingFailed + } + } func removingIdPrefixIfNeeded() -> Data { var result = self diff --git a/SessionUtilitiesKit/General/Dependencies.swift b/SessionUtilitiesKit/General/Dependencies.swift index 6a37e8475..0621482ef 100644 --- a/SessionUtilitiesKit/General/Dependencies.swift +++ b/SessionUtilitiesKit/General/Dependencies.swift @@ -4,10 +4,41 @@ import Foundation import GRDB open class Dependencies { - public var _generalCache: Atomic?> - public var generalCache: Atomic { - get { Dependencies.getValueSettingIfNull(&_generalCache) { General.cache } } - set { _generalCache.mutate { $0 = newValue } } + /// These should not be accessed directly but rather via an instance of this type + private static let _generalCacheInstance: MutableGeneralCacheType = General.Cache() + private static let _generalCacheInstanceAccessQueue = DispatchQueue(label: "GeneralCacheInstanceAccess") + + public var _subscribeQueue: Atomic + public var subscribeQueue: DispatchQueue { + get { Dependencies.getValueSettingIfNull(&_subscribeQueue) { DispatchQueue.global(qos: .default) } } + set { _subscribeQueue.mutate { $0 = newValue } } + } + + public var _receiveQueue: Atomic + public var receiveQueue: DispatchQueue { + get { Dependencies.getValueSettingIfNull(&_receiveQueue) { DispatchQueue.global(qos: .default) } } + set { _receiveQueue.mutate { $0 = newValue } } + } + + public var _mutableGeneralCache: Atomic + public var mutableGeneralCache: Atomic { + get { + Dependencies.getMutableValueSettingIfNull(&_mutableGeneralCache) { + Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance } + } + } + } + public var generalCache: GeneralCacheType { + get { + Dependencies.getValueSettingIfNull(&_mutableGeneralCache) { + Dependencies._generalCacheInstanceAccessQueue.sync { Dependencies._generalCacheInstance } + } + } + set { + guard let mutableValue: MutableGeneralCacheType = newValue as? MutableGeneralCacheType else { return } + + _mutableGeneralCache.mutate { $0 = mutableValue } + } } public var _storage: Atomic @@ -49,7 +80,9 @@ open class Dependencies { // MARK: - Initialization public init( - generalCache: Atomic? = nil, + subscribeQueue: DispatchQueue? = nil, + receiveQueue: DispatchQueue? = nil, + generalCache: MutableGeneralCacheType? = nil, storage: Storage? = nil, jobRunner: JobRunnerType? = nil, scheduler: ValueObservationScheduler? = nil, @@ -57,7 +90,9 @@ open class Dependencies { date: Date? = nil, fixedTime: Int? = nil ) { - _generalCache = Atomic(generalCache) + _subscribeQueue = Atomic(subscribeQueue) + _receiveQueue = Atomic(receiveQueue) + _mutableGeneralCache = Atomic(generalCache) _storage = Atomic(storage) _jobRunner = Atomic(jobRunner) _scheduler = Atomic(scheduler) @@ -77,4 +112,14 @@ open class Dependencies { return value } + + public static func getMutableValueSettingIfNull(_ maybeValue: inout Atomic, _ valueGenerator: () -> T) -> Atomic { + guard let value: T = maybeValue.wrappedValue else { + let value: T = valueGenerator() + maybeValue.mutate { $0 = value } + return Atomic(value) + } + + return Atomic(value) + } } diff --git a/SessionUtilitiesKit/General/Dictionary+Utilities.swift b/SessionUtilitiesKit/General/Dictionary+Utilities.swift index 694844492..7adfe64aa 100644 --- a/SessionUtilitiesKit/General/Dictionary+Utilities.swift +++ b/SessionUtilitiesKit/General/Dictionary+Utilities.swift @@ -36,7 +36,7 @@ public extension Dictionary { func getting(_ key: Key?) -> Value? { guard let key: Key = key else { return nil } - + return self[key] } diff --git a/SessionUtilitiesKit/General/Features.swift b/SessionUtilitiesKit/General/Features.swift index d23dabef6..970315b2d 100644 --- a/SessionUtilitiesKit/General/Features.swift +++ b/SessionUtilitiesKit/General/Features.swift @@ -1,6 +1,8 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -@objc(SNFeatures) -public final class Features : NSObject { - public static let useOnionRequests = true - public static let useTestnet = false +import Foundation + +public final class Features { + public static let useOnionRequests: Bool = true + public static let useTestnet: Bool = false } diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index b901af73a..5f59f2b13 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -2,36 +2,49 @@ import Foundation import GRDB -import Curve25519Kit -public protocol GeneralCacheType { - var encodedPublicKey: String? { get set } - var recentReactionTimestamps: [Int64] { get set } -} +// MARK: - General.Cache public enum General { - public class Cache: GeneralCacheType { + public class Cache: MutableGeneralCacheType { public var encodedPublicKey: String? = nil public var recentReactionTimestamps: [Int64] = [] } - - public static var cache: Atomic = Atomic(Cache()) } +// MARK: - GeneralError + public enum GeneralError: Error { case invalidSeed case keyGenerationFailed + case randomGenerationFailed } +// MARK: - Convenience + public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Dependencies = Dependencies()) -> String { - if let cachedKey: String = dependencies.generalCache.wrappedValue.encodedPublicKey { return cachedKey } + if let cachedKey: String = dependencies.generalCache.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 } + dependencies.mutableGeneralCache.mutate { $0.encodedPublicKey = sessionId.hexString } return sessionId.hexString } return "" } + +// MARK: - GeneralCacheType + +public protocol MutableGeneralCacheType: GeneralCacheType { + var encodedPublicKey: String? { get set } + var recentReactionTimestamps: [Int64] { get set } +} + +/// This is a read-only version of the `OGMMutableCacheType` designed to avoid unintentionally mutating the instance in a +/// non-thread-safe way +public protocol GeneralCacheType { + var encodedPublicKey: String? { get } + var recentReactionTimestamps: [Int64] { get } +} diff --git a/SessionUtilitiesKit/General/Logging.swift b/SessionUtilitiesKit/General/Logging.swift index e0c73a335..e759e83fd 100644 --- a/SessionUtilitiesKit/General/Logging.swift +++ b/SessionUtilitiesKit/General/Logging.swift @@ -1,8 +1,42 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation import SignalCoreKit -public func SNLog(_ message: String) { - #if DEBUG - print("[Session] \(message)") - #endif - OWSLogger.info("[Session] \(message)") +private extension DispatchQueue { + static var isDBWriteQueue: Bool { + /// The `dispatch_queue_get_label` function is used to get the label for a given DispatchQueue, in Swift this + /// was replaced with the `label` property on a queue instance but you used to be able to just pass `nil` in order + /// to get the name of the current queue - it seems that there might be a hole in the current design where there isn't + /// a built-in way to get the label of the current queue natively in Swift + /// + /// On a positive note it seems that we can safely call `__dispatch_queue_get_label(nil)` in order to do this, + /// it won't appear in auto-completed code but works properly + /// + /// For more information see + /// https://developer.apple.com/forums/thread/701313?answerId=705773022#705773022 + /// https://forums.swift.org/t/gcd-getting-current-dispatch-queue-name-with-swift-3/3039/2 + return (String(cString: __dispatch_queue_get_label(nil)) == "\(Storage.queuePrefix).writer") + } +} + +public func SNLog(_ message: String) { + let logPrefixes: String = [ + "Session", + (Thread.isMainThread ? "Main" : nil), + (DispatchQueue.isDBWriteQueue ? "DBWrite" : nil) + ] + .compactMap { $0 } + .joined(separator: ", ") + + #if DEBUG + print("[\(logPrefixes)] \(message)") + #endif + OWSLogger.info("[\(logPrefixes)] \(message)") +} + +public func SNLogNotTests(_ message: String) { + guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } + + SNLog(message) } diff --git a/SessionUtilitiesKit/General/NSNotificationCenter+OWS.h b/SessionUtilitiesKit/General/NSNotificationCenter+OWS.h deleted file mode 100644 index 2a97bda43..000000000 --- a/SessionUtilitiesKit/General/NSNotificationCenter+OWS.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -// We often use notifications as way to publish events. -// -// We never need these events to be received synchronously, -// so we should always send them asynchronously to avoid any -// possible risk of deadlock. These methods also ensure that -// the notifications are always fired on the main thread. -@interface NSNotificationCenter (OWS) - -- (void)postNotificationNameAsync:(NSNotificationName)name object:(nullable id)object; -- (void)postNotificationNameAsync:(NSNotificationName)name - object:(nullable id)object - userInfo:(nullable NSDictionary *)userInfo; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/General/NSNotificationCenter+OWS.m b/SessionUtilitiesKit/General/NSNotificationCenter+OWS.m deleted file mode 100644 index c406ff6b7..000000000 --- a/SessionUtilitiesKit/General/NSNotificationCenter+OWS.m +++ /dev/null @@ -1,29 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -#import "NSNotificationCenter+OWS.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation NSNotificationCenter (OWS) - -- (void)postNotificationNameAsync:(NSNotificationName)name object:(nullable id)object -{ - dispatch_async(dispatch_get_main_queue(), ^{ - [self postNotificationName:name object:object]; - }); -} - -- (void)postNotificationNameAsync:(NSNotificationName)name - object:(nullable id)object - userInfo:(nullable NSDictionary *)userInfo -{ - dispatch_async(dispatch_get_main_queue(), ^{ - [self postNotificationName:name object:object userInfo:userInfo]; - }); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/General/NSString+SSK.h b/SessionUtilitiesKit/General/NSString+SSK.h deleted file mode 100644 index 48c43f466..000000000 --- a/SessionUtilitiesKit/General/NSString+SSK.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSString (SSK) - -- (NSString *)rtlSafeAppend:(NSString *)string; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/General/NSString+SSK.m b/SessionUtilitiesKit/General/NSString+SSK.m deleted file mode 100644 index c69067838..000000000 --- a/SessionUtilitiesKit/General/NSString+SSK.m +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "NSString+SSK.h" -#import "AppContext.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation NSString (SSK) - -- (NSString *)rtlSafeAppend:(NSString *)string -{ - if (CurrentAppContext().isRTL) { - return [string stringByAppendingString:self]; - } else { - return [self stringByAppendingString:string]; - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SessionUtilitiesKit/General/SNUserDefaults.swift b/SessionUtilitiesKit/General/SNUserDefaults.swift index eda9cbcba..bc55e8231 100644 --- a/SessionUtilitiesKit/General/SNUserDefaults.swift +++ b/SessionUtilitiesKit/General/SNUserDefaults.swift @@ -38,8 +38,6 @@ public enum SNUserDefaults { public enum Date: Swift.String { case lastConfigurationSync - case lastDisplayNameUpdate - case lastProfilePictureUpdate case lastProfilePictureUpload case lastOpenGroupImageUpdate case lastOpen @@ -59,6 +57,7 @@ public enum SNUserDefaults { public enum String : Swift.String { case deviceToken + case topBannerWarningToShow } } diff --git a/SessionUtilitiesKit/General/SessionId.swift b/SessionUtilitiesKit/General/SessionId.swift index 7e251876e..ecf4bc3a5 100644 --- a/SessionUtilitiesKit/General/SessionId.swift +++ b/SessionUtilitiesKit/General/SessionId.swift @@ -2,12 +2,14 @@ import Foundation import Sodium -import Curve25519Kit public struct SessionId { + public static let byteCount: Int = 33 + 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 blinded15 = "15" // Used for authentication and participants in open groups with blinding enabled + case blinded25 = "25" // 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?) { @@ -19,7 +21,7 @@ public struct SessionId { return } - guard ECKeyPair.isValidHexEncodedPublicKey(candidate: stringValue) else { return nil } + guard KeyPair.isValidHexEncodedPublicKey(candidate: stringValue) else { return nil } guard let targetPrefix: Prefix = Prefix(rawValue: String(stringValue.prefix(2))) else { return nil } self = targetPrefix diff --git a/SessionUtilitiesKit/General/Set+Utilities.swift b/SessionUtilitiesKit/General/Set+Utilities.swift index f6f45d27a..346d96e0e 100644 --- a/SessionUtilitiesKit/General/Set+Utilities.swift +++ b/SessionUtilitiesKit/General/Set+Utilities.swift @@ -35,4 +35,11 @@ public extension Set { return updatedSet } + + mutating func popRandomElement() -> Element? { + guard let value: Element = randomElement() else { return nil } + + self.remove(value) + return value + } } diff --git a/SessionUtilitiesKit/General/Sodium+Utilities.swift b/SessionUtilitiesKit/General/Sodium+Utilities.swift index b9161af12..fcdf1d3db 100644 --- a/SessionUtilitiesKit/General/Sodium+Utilities.swift +++ b/SessionUtilitiesKit/General/Sodium+Utilities.swift @@ -3,7 +3,6 @@ import Foundation import Clibsodium import Sodium -import Curve25519Kit extension Sign { diff --git a/SessionUtilitiesKit/General/String+SSK.swift b/SessionUtilitiesKit/General/String+SSK.swift index ae50abb28..f888c654a 100644 --- a/SessionUtilitiesKit/General/String+SSK.swift +++ b/SessionUtilitiesKit/General/String+SSK.swift @@ -10,10 +10,6 @@ public extension String { return (self as NSString).digitsOnly() } - func rtlSafeAppend(_ string: String) -> String { - return (self as NSString).rtlSafeAppend(string) - } - func substring(from index: Int) -> String { return String(self[self.index(self.startIndex, offsetBy: index)...]) } diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 44074f0da..42cd4c5a9 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -1,5 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +import Foundation import SignalCoreKit public extension String { @@ -76,6 +77,23 @@ public extension String { // MARK: - Formatting +extension String.StringInterpolation { + mutating func appendInterpolation(_ value: Int, format: String) { + let result: String = String(format: "%\(format)d", value) + appendLiteral(result) + } + + mutating func appendInterpolation(_ value: Double, format: String, omitZeroDecimal: Bool = false) { + guard !omitZeroDecimal || Int(exactly: value) == nil else { + appendLiteral("\(Int(exactly: value)!)") + return + } + + let result: String = String(format: "%\(format)f", value) + appendLiteral(result) + } +} + public extension String { static func formattedDuration(_ duration: TimeInterval, format: TimeInterval.DurationFormat = .short) -> String { let secondsPerMinute: TimeInterval = 60 diff --git a/SessionUtilitiesKit/General/Timer+MainThread.swift b/SessionUtilitiesKit/General/Timer+MainThread.swift index b8a5ce314..7cea385a2 100644 --- a/SessionUtilitiesKit/General/Timer+MainThread.swift +++ b/SessionUtilitiesKit/General/Timer+MainThread.swift @@ -5,7 +5,11 @@ import Foundation extension Timer { @discardableResult - public static func scheduledTimerOnMainThread(withTimeInterval timeInterval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Void) -> Timer { + public static func scheduledTimerOnMainThread( + withTimeInterval timeInterval: TimeInterval, + repeats: Bool = false, + block: @escaping (Timer) -> Void + ) -> Timer { let timer = Timer(timeInterval: timeInterval, repeats: repeats, block: block) RunLoop.main.add(timer, forMode: .common) return timer diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index b1812398b..1f7d7e042 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -7,13 +7,11 @@ public protocol JobRunnerType { // MARK: - Configuration func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) - func canStart(queue: JobQueue) -> Bool + func canStart(queue: JobQueue?) -> Bool // MARK: - State Management - func isCurrentlyRunning(_ job: Job?) -> Bool - func hasJob(of variant: Job.Variant, inState state: JobRunner.JobState, with jobDetails: T) -> Bool - func detailsFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: Data?] + func jobInfoFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: JobRunner.JobInfo] func appDidFinishLaunching(dependencies: Dependencies) func appDidBecomeActive(dependencies: Dependencies) @@ -22,43 +20,61 @@ public protocol JobRunnerType { // MARK: - Job Scheduling - func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) + @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) -> Job? func upsert(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) @discardableResult func insert(_ db: Database, job: Job?, before otherJob: Job, dependencies: Dependencies) -> (Int64, Job)? } +// MARK: - JobRunnerType Convenience + public extension JobRunnerType { + func allJobInfo() -> [Int64: JobRunner.JobInfo] { return jobInfoFor(jobs: nil, state: .any, variant: nil) } + + func jobInfoFor(jobs: [Job]) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: jobs, state: .any, variant: nil) + } + + func jobInfoFor(jobs: [Job], state: JobRunner.JobState) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: jobs, state: state, variant: nil) + } + + func jobInfoFor(state: JobRunner.JobState) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: nil, state: state, variant: nil) + } + + func jobInfoFor(state: JobRunner.JobState, variant: Job.Variant) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: nil, state: state, variant: variant) + } + + func jobInfoFor(variant: Job.Variant) -> [Int64: JobRunner.JobInfo] { + return jobInfoFor(jobs: nil, state: .any, variant: variant) + } + + func isCurrentlyRunning(_ job: Job?) -> Bool { + guard let job: Job = job else { return false } + + return !jobInfoFor(jobs: [job], state: .running).isEmpty + } + + func hasJob( + of variant: Job.Variant? = nil, + inState state: JobRunner.JobState = .any, + with jobDetails: T + ) -> Bool { + guard let detailsData: Data = try? JSONEncoder().encode(jobDetails) else { return false } + + return jobInfoFor(jobs: nil, state: state, variant: variant) + .values + .contains(where: { $0.detailsData == detailsData }) + } + func stopAndClearPendingJobs(exceptForVariant: Job.Variant? = nil, onComplete: (() -> ())? = nil) { stopAndClearPendingJobs(exceptForVariant: exceptForVariant, onComplete: onComplete) } - - func hasJob(of variant: Job.Variant, inState state: JobRunner.JobState = .any, with jobDetails: T) -> Bool { - return hasJob(of: variant, inState: state, with: jobDetails) - } - - func details() -> [Int64: Data?] { return detailsFor(jobs: nil, state: .any, variant: nil) } - - func detailsFor(jobs: [Job]) -> [Int64: Data?] { - return detailsFor(jobs: jobs, state: .any, variant: nil) - } - - func detailsFor(jobs: [Job], state: JobRunner.JobState) -> [Int64: Data?] { - return detailsFor(jobs: jobs, state: state, variant: nil) - } - - func detailsFor(state: JobRunner.JobState) -> [Int64: Data?] { - return detailsFor(jobs: nil, state: state, variant: nil) - } - - func detailsFor(state: JobRunner.JobState, variant: Job.Variant) -> [Int64: Data?] { - return detailsFor(jobs: nil, state: state, variant: variant) - } - - func detailsFor(variant: Job.Variant) -> [Int64: Data?] { - return detailsFor(jobs: nil, state: .any, variant: variant) - } } +// MARK: - JobExecutor + public protocol JobExecutor { /// The maximum number of times the job can fail before it fails permanently /// @@ -91,6 +107,8 @@ public protocol JobExecutor { ) } +// MARK: - JobRunner + public final class JobRunner: JobRunnerType { public struct JobState: OptionSet, Hashable { public let rawValue: UInt8 @@ -111,18 +129,32 @@ public final class JobRunner: JobRunnerType { case deferred case notFound } + + public struct JobInfo: Equatable { + public let variant: Job.Variant + public let threadId: String? + public let interactionId: Int64? + public let detailsData: Data? + } // MARK: - Variables private let allowToExecuteJobs: Bool private let blockingQueue: Atomic private let queues: Atomic<[Job.Variant: JobQueue]> + private var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) internal var appReadyToStartQueues: Atomic = Atomic(false) internal var perSessionJobsCompleted: Atomic> = Atomic([]) internal var hasCompletedInitialBecomeActive: Atomic = Atomic(false) internal var shutdownBackgroundTask: Atomic = Atomic(nil) + // TODO: Check these??? + internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) + fileprivate static var canStartQueues: Atomic = Atomic(false) + private static var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) + + // MARK: - Initialization init( @@ -145,12 +177,7 @@ public final class JobRunner: JobRunnerType { JobQueue( type: .blocking, qos: .default, - jobVariants: [], - onQueueDrained: { - // Once all blocking jobs have been completed we want to start running - // the remaining job queues - dependencies.jobRunner.startNonBlockingQueues(dependencies: dependencies) - } + jobVariants: [] ) ) self.queues = Atomic([ @@ -165,7 +192,8 @@ public final class JobRunner: JobRunnerType { jobVariants.remove(.messageSend), jobVariants.remove(.notifyPushServer), jobVariants.remove(.sendReadReceipts), - jobVariants.remove(.groupLeaving) + jobVariants.remove(.groupLeaving), + jobVariants.remove(.configurationSync) ].compactMap { $0 } ), @@ -181,7 +209,8 @@ public final class JobRunner: JobRunnerType { executionType: .serial, qos: .default, jobVariants: [ - jobVariants.remove(.messageReceive) + jobVariants.remove(.messageReceive), + jobVariants.remove(.configMessageReceive) ].compactMap { $0 } ), @@ -207,8 +236,42 @@ public final class JobRunner: JobRunnerType { prev[variant] = next } }) + + // Now that we've finished setting up the JobRunner, update the queue closures + self.blockingQueue.mutate { + $0?.canStart = { [weak self] queue -> Bool in (self?.canStart(queue: queue) == true) } + $0?.onQueueDrained = { [weak self] in + // Once all blocking jobs have been completed we want to start running + // the remaining job queues + self?.startNonBlockingQueues(dependencies: dependencies) + + self?.blockingQueueDrainCallback.mutate { + $0.forEach { $0() } + $0 = [] + } + } + } + + self.queues.mutate { + $0.values.forEach { queue in + queue.canStart = { [weak self] targetQueue -> Bool in (self?.canStart(queue: targetQueue) == true) } + } + } } + // TODO: Check if any of these are needed + //internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) + //fileprivate static var perSessionJobsCompleted: Atomic> = Atomic([]) + //private static var hasCompletedInitialBecomeActive: Atomic = Atomic(false) + //private static var shutdownBackgroundTask: Atomic = Atomic(nil) + //fileprivate static var canStartQueues: Atomic = Atomic(false) + //private static var blockingQueueDrainCallback: Atomic<[() -> ()]> = Atomic([]) + // + //fileprivate static var canStartNonBlockingQueue: Bool { + // blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue == true && + // blockingQueue.wrappedValue?.isRunning.wrappedValue != true + //} + // MARK: - Configuration public func setExecutor(_ executor: JobExecutor.Type, for variant: Job.Variant) { @@ -216,12 +279,55 @@ public final class JobRunner: JobRunnerType { queues.wrappedValue[variant]?.setExecutor(executor, for: variant) } - public func canStart(queue: JobQueue) -> Bool { - return ( - allowToExecuteJobs && - appReadyToStartQueues.wrappedValue - ) - } +// TODO: Double chekc this + //public func canStart(queue: JobQueue) -> Bool { + // return ( + // allowToExecuteJobs && + // appReadyToStartQueues.wrappedValue + // ) + + // TODO: Double check this + //public static func afterBlockingQueue(callback: @escaping () -> ()) { + // guard + // (blockingQueue.wrappedValue?.hasStartedAtLeastOnce.wrappedValue != true) || + // (blockingQueue.wrappedValue?.isRunning.wrappedValue == true) + // else { return callback() } + // + // blockingQueueDrainCallback.mutate { $0.append(callback) } + //} + + // 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 + //@discardableResult public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) -> Job? { + // // 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 + // } + // guard !canStartJob || updatedJob.id != nil else { + // SNLog("[JobRunner] Not starting \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") + // return nil + // } + // + // // Wait until the transaction has been completed before updating the queue (to ensure anything + // // created during the transaction has been saved to the database before any corresponding jobs + // // are run) + // db.afterNextTransactionNested { _ in + // queues.wrappedValue[updatedJob.variant]?.add(updatedJob, canStartJob: canStartJob) + // + // // Don't start the queue if the job can't be started + // guard canStartJob else { return } + // + // queues.wrappedValue[updatedJob.variant]?.start() + // } + // + // return updatedJob + //} // MARK: - State Management @@ -236,6 +342,51 @@ public final class JobRunner: JobRunnerType { return detailsFor(state: state, variant: variant).values.contains(detailsData) } + + // // Wait until the transaction has been completed before updating the queue (to ensure anything + // // created during the transaction has been saved to the database before any corresponding jobs + // // are run) + // db.afterNextTransactionNested { _ in + // queues.wrappedValue[job.variant]?.upsert(job, canStartJob: canStartJob) + // + // // Don't start the queue if the job can't be started + // guard canStartJob else { return } + // + // queues.wrappedValue[job.variant]?.start() + // } + //} + + ///// Insert a job before another job in the queue + ///// + ///// **Note:** This function assumes the relevant job queue is already running and as such **will not** start the queue if it isn't running + //@discardableResult public static func insert(_ db: Database, job: Job?, before otherJob: Job) -> (Int64, 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 + // } + // guard let jobId: Int64 = updatedJob.id else { + // SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job due to missing id") + // return nil + // } + // + // // Wait until the transaction has been completed before updating the queue (to ensure anything + // // created during the transaction has been saved to the database before any corresponding jobs + // // are run) + // db.afterNextTransactionNested { _ in + // queues.wrappedValue[updatedJob.variant]?.insert(updatedJob, before: otherJob) + // } + // + // return (jobId, updatedJob) + //} public func detailsFor( jobs: [Job]?, @@ -310,7 +461,10 @@ public final class JobRunner: JobRunnerType { ].contains(Job.Columns.behaviour) ) .filter(Job.Columns.shouldBlock == true) - .order(Job.Columns.id) + .order( + Job.Columns.priority.desc, + Job.Columns.id + ) .fetchAll(db) let nonblockingJobs: [Job] = try Job .filter( @@ -320,7 +474,10 @@ public final class JobRunner: JobRunnerType { ].contains(Job.Columns.behaviour) ) .filter(Job.Columns.shouldBlock == false) - .order(Job.Columns.id) + .order( + Job.Columns.priority.desc, + Job.Columns.id + ) .fetchAll(db) return (blockingJobs, nonblockingJobs) @@ -363,7 +520,10 @@ public final class JobRunner: JobRunnerType { .read { db in return try Job .filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive) - .order(Job.Columns.id) + .order( + Job.Columns.priority.desc, + Job.Columns.id + ) .fetchAll(db) } .defaulting(to: []) @@ -508,6 +668,11 @@ public final class JobRunner: JobRunnerType { self?.queues.wrappedValue[job.variant]?.start(dependencies: dependencies) } } + + //public static func infoForCurrentlyRunningJobs(of variant: Job.Variant) -> [Int64: JobInfo] { + // return (queues.wrappedValue[variant]?.infoForAllCurrentlyRunningJobs()) + // .defaulting(to: [:]) + //} @discardableResult public func insert( _ db: Database, @@ -553,6 +718,26 @@ public final class JobRunner: JobRunnerType { queues.wrappedValue[job.variant]?.removePendingJob(jobId) } + + //public static func hasPendingOrRunningJob( + // with variant: Job.Variant, + // threadId: String? = nil, + // interactionId: Int64? = nil, + // details: T? = nil + //) -> Bool { + // guard let targetQueue: JobQueue = queues.wrappedValue[variant] else { return false } + // + // // Ensure we can encode the details (if provided) + // let detailsData: Data? = details.map { try? JSONEncoder().encode($0) } + // + // guard details == nil || detailsData != nil else { return false } + // + // return targetQueue.hasPendingOrRunningJobWith( + // threadId: threadId, + // interactionId: interactionId, + // detailsData: detailsData + // ) + //} // MARK: - Convenience @@ -686,10 +871,14 @@ public final class JobQueue { private var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:]) private var nextTrigger: Atomic = Atomic(nil) + fileprivate var hasStartedAtLeastOnce: Atomic = Atomic(false) fileprivate var isRunning: Atomic = Atomic(false) fileprivate var pendingJobsQueue: Atomic<[Job]> = Atomic([]) fileprivate var jobsCurrentlyRunning: Atomic> = Atomic([]) + // TODO: Check these fileprivate var detailsForCurrentlyRunningJobs: Atomic<[JobKey: Data?]> = Atomic([:]) + private var currentlyRunningJobIds: Atomic> = Atomic([]) + private var currentlyRunningJobInfo: Atomic<[Int64: JobRunner.JobInfo]> = Atomic([:]) private var jobCallbacks: Atomic<[Int64: [(JobRunner.JobResult) -> ()]]> = Atomic([:]) private var deferLoopTracker: Atomic<[Int64: (count: Int, times: [TimeInterval])]> = Atomic([:]) @@ -813,7 +1002,6 @@ public final class JobQueue { canStart: Bool, dependencies: Dependencies ) { - let currentlyRunningJobIds: Set = jobsCurrentlyRunning.wrappedValue pendingJobsQueue.mutate { queue in // Avoid re-adding jobs to the queue that are already in it (this can @@ -834,6 +1022,14 @@ public final class JobQueue { } } + //fileprivate func isCurrentlyRunning(_ jobId: Int64) -> Bool { + // return currentlyRunningJobIds.wrappedValue.contains(jobId) + //} + // + //fileprivate func infoForAllCurrentlyRunningJobs() -> [Int64: JobRunner.JobInfo] { + // return currentlyRunningJobInfo.wrappedValue + //} + fileprivate func afterCurrentlyRunningJob(_ jobId: Int64, callback: @escaping (JobRunner.JobResult) -> ()) { guard jobsCurrentlyRunning.wrappedValue.contains(jobId) else { callback(.notFound) @@ -845,14 +1041,69 @@ public final class JobQueue { } } - fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { - guard let detailsData: Data = detailsData else { return false } + //fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { + // guard let detailsData: Data = detailsData else { return false } + // + // let pendingJobs: [Job] = pendingJobsQueue.wrappedValue + fileprivate func hasPendingOrRunningJobWith( + threadId: String? = nil, + interactionId: Int64? = nil, + detailsData: Data? = nil + ) -> Bool { + let pendingJobs: [Job] = queue.wrappedValue + let currentlyRunningJobInfo: [Int64: JobRunner.JobInfo] = currentlyRunningJobInfo.wrappedValue + var possibleJobIds: Set = Set(currentlyRunningJobInfo.keys) + .inserting(contentsOf: pendingJobs.compactMap { $0.id }.asSet()) - let pendingJobs: [Job] = pendingJobsQueue.wrappedValue + // Remove any which don't have the matching threadId (if provided) + if let targetThreadId: String = threadId { + let pendingJobIdsWithWrongThreadId: Set = pendingJobs + .filter { $0.threadId != targetThreadId } + .compactMap { $0.id } + .asSet() + let runningJobIdsWithWrongThreadId: Set = currentlyRunningJobInfo + .filter { _, info -> Bool in info.threadId != targetThreadId } + .map { key, _ in key } + .asSet() + + possibleJobIds = possibleJobIds + .subtracting(pendingJobIdsWithWrongThreadId) + .subtracting(runningJobIdsWithWrongThreadId) + } - guard !pendingJobs.contains(where: { job in job.details == detailsData }) else { return true } + // Remove any which don't have the matching interactionId (if provided) + if let targetInteractionId: Int64 = interactionId { + let pendingJobIdsWithWrongInteractionId: Set = pendingJobs + .filter { $0.interactionId != targetInteractionId } + .compactMap { $0.id } + .asSet() + let runningJobIdsWithWrongInteractionId: Set = currentlyRunningJobInfo + .filter { _, info -> Bool in info.interactionId != targetInteractionId } + .map { key, _ in key } + .asSet() + + possibleJobIds = possibleJobIds + .subtracting(pendingJobIdsWithWrongInteractionId) + .subtracting(runningJobIdsWithWrongInteractionId) + } - return detailsForCurrentlyRunningJobs.wrappedValue.values.contains(detailsData) + // Remove any which don't have the matching details (if provided) + if let targetDetailsData: Data = detailsData { + let pendingJobIdsWithWrongDetailsData: Set = pendingJobs + .filter { $0.details != targetDetailsData } + .compactMap { $0.id } + .asSet() + let runningJobIdsWithWrongDetailsData: Set = currentlyRunningJobInfo + .filter { _, info -> Bool in info.detailsData != detailsData } + .map { key, _ in key } + .asSet() + + possibleJobIds = possibleJobIds + .subtracting(pendingJobIdsWithWrongDetailsData) + .subtracting(runningJobIdsWithWrongDetailsData) + } + + return !possibleJobIds.isEmpty } fileprivate func removePendingJob(_ jobId: Int64) { @@ -870,6 +1121,18 @@ public final class JobQueue { // Only start if the JobRunner is allowed to start the queue guard dependencies.jobRunner.canStart(queue: self) else { return } guard forceWhenAlreadyRunning || !isRunning.wrappedValue else { return } +// TODO: Check this + // We only want the JobRunner to run in the main app + //guard + // HasAppContext() && + // CurrentAppContext().isMainApp && + // !CurrentAppContext().isRunningTests && + // JobRunner.canStartQueues.wrappedValue && + // ( + // type == .blocking || + // JobRunner.canStartNonBlockingQueue + // ) + //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) @@ -887,11 +1150,14 @@ public final class JobQueue { wasAlreadyRunning = isRunning isRunning = true } + hasStartedAtLeastOnce.mutate { $0 = true } // Get any pending jobs let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue let jobsAlreadyInQueue: Set = pendingJobsQueue.wrappedValue.compactMap { $0.id }.asSet() let jobsToRun: [Job] = dependencies.storage.read { db in +// TODO: Check this + //let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue try Job .filterPendingJobs( variants: jobVariants, @@ -948,7 +1214,7 @@ public final class JobQueue { } guard let (nextJob, numJobsRemaining): (Job, Int) = pendingJobsQueue.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 { + if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty { isRunning.mutate { $0 = false } } @@ -1036,6 +1302,8 @@ public final class JobQueue { /// **Note:** We don't add the current job back the the queue because it should only be re-added if it's dependencies /// are successfully completed let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys.map { $0.id }) + // TODO: CHeck this + //let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) let dependencyJobsNotCurrentlyRunning: [Job] = dependencyInfo.jobs .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } @@ -1059,14 +1327,25 @@ public final class JobQueue { trigger?.invalidate() // Need to invalidate to prevent a memory leak trigger = nil } - jobsCurrentlyRunning.mutate { jobsCurrentlyRunning in - jobsCurrentlyRunning = jobsCurrentlyRunning.inserting(nextJob.id) - numJobsRunning = jobsCurrentlyRunning.count + currentlyRunningJobIds.mutate { currentlyRunningJobIds in + currentlyRunningJobIds = currentlyRunningJobIds.inserting(nextJob.id) + numJobsRunning = currentlyRunningJobIds.count } + currentlyRunningJobInfo.mutate { currentlyRunningJobInfo in + currentlyRunningJobInfo = currentlyRunningJobInfo.setting( + nextJob.id, + JobRunner.JobInfo( + threadId: nextJob.threadId, + interactionId: nextJob.interactionId, + detailsData: nextJob.details + ) + ) + } +// TODO: Check this detailsForCurrentlyRunningJobs.mutate { $0 = $0.setting(JobKey(nextJob), nextJob.details) } SNLog("[JobRunner] \(queueContext) started \(nextJob.variant) job (\(executionType == .concurrent ? "\(numJobsRunning) currently running, " : "")\(numJobsRemaining) remaining)") - /// As it turns out Combine doesn't play too nicely with concurrent Dispatch Queues, in Combine events are dispatched asynchronously to + /// As it turns out Combine doesn't plat too nicely with concurrent Dispatch Queues, in Combine events are dispatched asynchronously to /// the queue which means an odd situation can occasionally occur where the `finished` event can actually run before the `output` /// event - this can result in unexpected behaviours (for more information see https://github.com/groue/GRDB.swift/issues/1334) /// @@ -1105,6 +1384,8 @@ public final class JobQueue { private func scheduleNextSoonestJob(dependencies: Dependencies) { let jobIdsAlreadyRunning: Set = jobsCurrentlyRunning.wrappedValue let nextJobTimestamp: TimeInterval? = dependencies.storage.read { db in + // TODO: Check this + //let jobIdsAlreadyRunning: Set = currentlyRunningJobIds.wrappedValue try Job .filterPendingJobs( variants: jobVariants, @@ -1121,6 +1402,8 @@ public final class JobQueue { // the 'onQueueDrained' callback and stop guard let nextJobTimestamp: TimeInterval = nextJobTimestamp, dependencies.jobRunner.canStart(queue: self) else { if executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty { + // TODO: Chgeck this + //if executionType != .concurrent || currentlyRunningJobIds.wrappedValue.isEmpty { self.onQueueDrained?() } return @@ -1135,7 +1418,7 @@ public final class JobQueue { 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 { + if executionType != .concurrent || currentlyRunningJobIds.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" @@ -1153,7 +1436,7 @@ public final class JobQueue { } // Only schedule a trigger if this queue has actually completed - guard executionType != .concurrent || jobsCurrentlyRunning.wrappedValue.isEmpty else { return } + guard executionType != .concurrent || currentlyRunningJobIds.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")") @@ -1245,6 +1528,8 @@ public final class JobQueue { /// removed from the queue, replaced by their dependencies if !dependantJobs.isEmpty { let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys.map { $0.id }) + // TODO: CHeck this + //let currentlyRunningJobIds: [Int64] = Array(currentlyRunningJobIds.wrappedValue) let dependantJobsNotCurrentlyRunning: [Job] = dependantJobs .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } @@ -1315,21 +1600,16 @@ public final class JobQueue { // Get the max failure count for the job (a value of '-1' means it will retry indefinitely) let maxFailureCount: Int = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0) let nextRunTimestamp: TimeInterval = (dependencies.date.timeIntervalSince1970 + JobRunner.getRetryInterval(for: job)) + var dependantJobIds: [Int64] = [] + var failureText: String = "failed" dependencies.storage.write { db in - /// Remove any dependant jobs from the queue (shouldn't be in there but filter the queue just in case so we don't try - /// to run a deleted job or get stuck in a loop of trying to run dependencies indefinitely) - let dependantJobIds: [Int64] = try job.dependantJobs + /// Retrieve a list of dependant jobs so we can clear them from the queue + dependantJobIds = try job.dependantJobs .select(.id) .asRequest(of: Int64.self) .fetchAll(db) - if !dependantJobIds.isEmpty { - pendingJobsQueue.mutate { queue in - queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } - } - } - /// Delete/update the failed jobs and any dependencies let updatedFailureCount: UInt = (job.failureCount + 1) @@ -1339,7 +1619,10 @@ public final class JobQueue { updatedFailureCount <= maxFailureCount ) else { - SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 && updatedFailureCount > maxFailureCount ? "; too many retries" : "")") + failureText = (maxFailureCount >= 0 && updatedFailureCount > maxFailureCount ? + "failed permanently; too many retries" : + "failed permanently" + ) // 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) @@ -1347,12 +1630,10 @@ public final class JobQueue { .deleteAll(db) _ = try job.delete(db) - - performCleanUp(for: job, result: .failed) return } - SNLog("[JobRunner] \(queueContext) \(job.variant) job failed; scheduling retry (failure count is \(updatedFailureCount))") + failureText = "failed; scheduling retry (failure count is \(updatedFailureCount))" _ = try job .with( @@ -1372,6 +1653,15 @@ public final class JobQueue { ) } + /// Remove any dependant jobs from the queue (shouldn't be in there but filter the queue just in case so we don't try + /// to run a deleted job or get stuck in a loop of trying to run dependencies indefinitely) + if !dependantJobIds.isEmpty { + pendingJobsQueue.mutate { queue in + queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } + } + } + + SNLog("[JobRunner] \(queueContext) \(job.variant) job \(failureText)") performCleanUp(for: job, result: .failed) internalQueue.async { [weak self] in self?.runNextJob(dependencies: dependencies) @@ -1441,6 +1731,9 @@ public final class JobQueue { // from the 'currentlyRunning' set jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) } detailsForCurrentlyRunningJobs.mutate { $0 = $0.removingValue(forKey: JobKey(job)) } + // TODO: CHeck this + //currentlyRunningJobIds.mutate { $0 = $0.removing(job.id) } + //currentlyRunningJobInfo.mutate { $0 = $0.removingValue(forKey: job.id) } guard shouldTriggerCallbacks else { return } @@ -1476,6 +1769,10 @@ public extension JobRunner { instance.appDidBecomeActive(dependencies: dependencies) } + static func afterBlockingQueue(callback: @escaping () -> ()) { + instance.afterBlockingQueue(callback: callback) + } + /// Add a job onto the queue, if the queue isn't currently running and 'canStartJob' is true then this will start /// the JobRunner /// diff --git a/SessionUtilitiesKit/Media/NSData+Image.m b/SessionUtilitiesKit/Media/NSData+Image.m index fb13b9d20..cda178f5e 100644 --- a/SessionUtilitiesKit/Media/NSData+Image.m +++ b/SessionUtilitiesKit/Media/NSData+Image.m @@ -157,14 +157,6 @@ typedef struct { return CGSizeZero; } - const CGFloat kExpectedBytePerPixel = 4; - CGFloat kMaxValidImageDimension = OWSMediaUtils.kMaxAnimatedImageDimensions; - CGFloat kMaxBytes = kMaxValidImageDimension * kMaxValidImageDimension * kExpectedBytePerPixel; - - if (data.length > kMaxBytes) { - return CGSizeZero; - } - return imageSize; } @@ -176,7 +168,8 @@ typedef struct { ImageDimensionInfo dimensionInfo = [self ows_imageDimensionWithImageSource:imageSource isAnimated:isAnimated]; CFRelease(imageSource); - if (![self ows_isValidImageDimension:dimensionInfo.pixelSize depthBytes:dimensionInfo.depthBytes isAnimated:isAnimated]) { + if (dimensionInfo.pixelSize.width < 1 || dimensionInfo.pixelSize.height < 1 || dimensionInfo.depthBytes < 1) { + // Invalid metadata. return CGSizeZero; } diff --git a/SessionUtilitiesKit/Media/Updatable.swift b/SessionUtilitiesKit/Media/Updatable.swift deleted file mode 100644 index 4a4a39495..000000000 --- a/SessionUtilitiesKit/Media/Updatable.swift +++ /dev/null @@ -1,121 +0,0 @@ -// 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/Meta/SessionUtilitiesKit.h b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h index 1a6f9e631..7bd86403d 100644 --- a/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h +++ b/SessionUtilitiesKit/Meta/SessionUtilitiesKit.h @@ -7,8 +7,6 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[]; #import #import #import -#import -#import #import #import #import @@ -16,4 +14,5 @@ FOUNDATION_EXPORT const unsigned char SessionUtilitiesKitVersionString[]; #import #import #import +#import diff --git a/SessionUtilitiesKit/Networking/BatchResponse.swift b/SessionUtilitiesKit/Networking/BatchResponse.swift new file mode 100644 index 000000000..600b82982 --- /dev/null +++ b/SessionUtilitiesKit/Networking/BatchResponse.swift @@ -0,0 +1,148 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine + +public extension HTTP { + // MARK: - BatchResponse + + struct BatchResponse { + public let info: ResponseInfoType + public let responses: [Decodable] + + public static func decodingResponses( + from data: Data?, + as types: [Decodable.Type], + requireAllResults: Bool, + using dependencies: Dependencies = Dependencies() + ) throws -> [Decodable] { + // Need to split the data into an array of data so each item can be Decoded correctly + guard let data: Data = data else { throw HTTPError.parsingFailed } + guard let jsonObject: Any = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) else { + throw HTTPError.parsingFailed + } + + let dataArray: [Data] + + switch jsonObject { + case let anyArray as [Any]: + dataArray = anyArray.compactMap { try? JSONSerialization.data(withJSONObject: $0) } + + guard !requireAllResults || dataArray.count == types.count else { + throw HTTPError.parsingFailed + } + + case let anyDict as [String: Any]: + guard + let resultsArray: [Data] = (anyDict["results"] as? [Any])? + .compactMap({ try? JSONSerialization.data(withJSONObject: $0) }), + ( + !requireAllResults || + resultsArray.count == types.count + ) + else { throw HTTPError.parsingFailed } + + dataArray = resultsArray + + default: throw HTTPError.parsingFailed + } + + return try zip(dataArray, types) + .map { data, type in try type.decoded(from: data, using: dependencies) } + } + } + + // MARK: - BatchSubResponse + + struct BatchSubResponse: BatchSubResponseType { + public enum CodingKeys: String, CodingKey { + case code + case headers + case body + } + + /// The numeric http response code (e.g. 200 for success) + public let code: Int + + /// Any headers returned by the request + public 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 + public let body: T? + + /// A flag to indicate that there was a body but it failed to parse + public let failedToParseBody: Bool + + public init( + code: Int, + headers: [String: String] = [:], + body: T? = nil, + failedToParseBody: Bool = false + ) { + self.code = code + self.headers = headers + self.body = body + self.failedToParseBody = failedToParseBody + } + } +} + +public protocol BatchSubResponseType: Decodable { + var code: Int { get } + var headers: [String: String] { get } + var failedToParseBody: Bool { get } +} + +extension BatchSubResponseType { + public var responseInfo: ResponseInfoType { HTTP.ResponseInfo(code: code, headers: headers) } +} + +extension HTTP.BatchSubResponse: Encodable where T: Encodable {} + +public extension HTTP.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 = HTTP.BatchSubResponse( + code: try container.decode(Int.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: - 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) + } +} + +public extension Publisher where Output == (ResponseInfoType, Data?), Failure == Error { + func decoded( + as types: [Decodable.Type], + requireAllResults: Bool = true, + using dependencies: Dependencies = Dependencies() + ) -> AnyPublisher { + self + .tryMap { responseInfo, maybeData -> HTTP.BatchResponse in + HTTP.BatchResponse( + info: responseInfo, + responses: try HTTP.BatchResponse.decodingResponses( + from: maybeData, + as: types, + requireAllResults: requireAllResults, + using: dependencies + ) + ) + } + .eraseToAnyPublisher() + } +} diff --git a/SessionUtilitiesKit/Networking/ContentProxy.swift b/SessionUtilitiesKit/Networking/ContentProxy.swift index 4dd641ae4..5c5710fb8 100644 --- a/SessionUtilitiesKit/Networking/ContentProxy.swift +++ b/SessionUtilitiesKit/Networking/ContentProxy.swift @@ -1,19 +1,10 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -import AFNetworking import Foundation -@objc -public class ContentProxy: NSObject { +public enum ContentProxy { - @available(*, unavailable, message:"do not instantiate this class.") - private override init() { - } - - @objc - public class func sessionConfiguration() -> URLSessionConfiguration { + public static func sessionConfiguration() -> URLSessionConfiguration { let configuration = URLSessionConfiguration.ephemeral let proxyHost = "contentproxy.signal.org" let proxyPort = 443 @@ -28,32 +19,9 @@ public class ContentProxy: NSObject { return configuration } - @objc - public class func sessionManager(baseUrl baseUrlString: String?) -> AFHTTPSessionManager? { - guard let baseUrlString = baseUrlString else { - return AFHTTPSessionManager(baseURL: nil, sessionConfiguration: sessionConfiguration()) - } - guard let baseUrl = URL(string: baseUrlString) else { - return nil - } - let sessionManager = AFHTTPSessionManager(baseURL: baseUrl, - sessionConfiguration: sessionConfiguration()) - return sessionManager - } - - @objc - public class func jsonSessionManager(baseUrl: String) -> AFHTTPSessionManager? { - guard let sessionManager = self.sessionManager(baseUrl: baseUrl) else { - return nil - } - sessionManager.requestSerializer = AFJSONRequestSerializer() - sessionManager.responseSerializer = AFJSONResponseSerializer() - return sessionManager - } - static let userAgent = "Signal iOS (+https://signal.org/download)" - public class func configureProxiedRequest(request: inout URLRequest) -> Bool { + public static func configureProxiedRequest(request: inout URLRequest) -> Bool { request.addValue(userAgent, forHTTPHeaderField: "User-Agent") padRequestSize(request: &request) @@ -66,39 +34,7 @@ public class ContentProxy: NSObject { return true } - // This mutates the session manager state, so its the caller's obligation to avoid conflicts by: - // - // * Using a new session manager for each request. - // * Pooling session managers. - // * Using a single session manager on a single queue. - @objc - public class func configureSessionManager(sessionManager: AFHTTPSessionManager, - forUrl urlString: String) -> Bool { - - guard let url = URL(string: urlString, relativeTo: sessionManager.baseURL) else { - return false - } - - var request = URLRequest(url: url) - - guard configureProxiedRequest(request: &request) else { - return false - } - - // Remove all headers from the request. - for headerField in sessionManager.requestSerializer.httpRequestHeaders.keys { - sessionManager.requestSerializer.setValue(nil, forHTTPHeaderField: headerField) - } - // Honor the request's headers. - if let allHTTPHeaderFields = request.allHTTPHeaderFields { - for (headerField, headerValue) in allHTTPHeaderFields { - sessionManager.requestSerializer.setValue(headerValue, forHTTPHeaderField: headerField) - } - } - return true - } - - public class func padRequestSize(request: inout URLRequest) { + public static func padRequestSize(request: inout URLRequest) { // Generate 1-64 chars of padding. let paddingLength: Int = 1 + Int(arc4random_uniform(64)) let padding = self.padding(withLength: paddingLength) @@ -106,7 +42,7 @@ public class ContentProxy: NSObject { request.addValue(padding, forHTTPHeaderField: "X-SignalPadding") } - private class func padding(withLength length: Int) -> String { + private static func padding(withLength length: Int) -> String { // Pick a random ASCII char in the range 48-122 var result = "" // Min and max values, inclusive. diff --git a/SessionUtilitiesKit/Networking/HTTP.swift b/SessionUtilitiesKit/Networking/HTTP.swift index 1c5f586c0..e22a80af6 100644 --- a/SessionUtilitiesKit/Networking/HTTP.swift +++ b/SessionUtilitiesKit/Networking/HTTP.swift @@ -1,5 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import Foundation -import PromiseKit +import Combine public enum HTTP { private static let seedNodeURLSession = URLSession(configuration: .ephemeral, delegate: seedNodeURLSessionDelegate, delegateQueue: nil) @@ -7,7 +9,7 @@ public enum HTTP { private static let snodeURLSession = URLSession(configuration: .ephemeral, delegate: snodeURLSessionDelegate, delegateQueue: nil) private static let snodeURLSessionDelegate = SnodeURLSessionDelegateImplementation() - // MARK: Certificates + // MARK: - Certificates /// **Note:** These certificates will need to be regenerated and replaced at the start of April 2025, iOS has a restriction after iOS 13 /// where certificates can have a maximum lifetime of 825 days (https://support.apple.com/en-au/HT210176) as a result we @@ -30,10 +32,12 @@ public enum HTTP { return SecCertificateCreateWithData(nil, data as CFData)! }() - // MARK: Settings - public static let timeout: TimeInterval = 10 + // MARK: - Settings + + public static let defaultTimeout: TimeInterval = 10 - // MARK: Seed Node URL Session Delegate Implementation + // MARK: - Seed Node URL Session Delegate Implementation + private final class SeedNodeURLSessionDelegateImplementation : NSObject, URLSessionDelegate { func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { @@ -89,7 +93,8 @@ public enum HTTP { } } - // MARK: Snode URL Session Delegate Implementation + // MARK: - Snode URL Session Delegate Implementation + private final class SnodeURLSessionDelegateImplementation : NSObject, URLSessionDelegate { func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { @@ -97,112 +102,84 @@ public enum HTTP { completionHandler(.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!)) } } - - // 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, Equatable { - case generic - case invalidURL - case invalidJSON - case parsingFailed - case invalidResponse - case maxFileSizeExceeded - case httpRequestFailed(statusCode: UInt, data: Data?) - case timeout + // MARK: - Execution - public var errorDescription: String? { - switch self { - 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." - } - } + public static func execute( + _ method: HTTPMethod, + _ url: String, + timeout: TimeInterval = HTTP.defaultTimeout, + useSeedNodeURLSession: Bool = false + ) -> AnyPublisher { + return execute( + method, + url, + body: nil, + timeout: timeout, + useSeedNodeURLSession: useSeedNodeURLSession + ) } - - // 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 { - 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) { - return Promise(error: error) - } + public static func execute( + _ method: HTTPMethod, + _ url: String, + body: Data?, + timeout: TimeInterval = HTTP.defaultTimeout, + useSeedNodeURLSession: Bool = false + ) -> AnyPublisher { + guard let url: URL = URL(string: url) else { + return Fail(error: HTTPError.invalidURL) + .eraseToAnyPublisher() } - else { - return execute(verb, url, body: nil, timeout: timeout, useSeedNodeURLSession: useSeedNodeURLSession) - } - } - - public static func execute(_ verb: Verb, _ url: String, body: Data?, timeout: TimeInterval = HTTP.timeout, useSeedNodeURLSession: Bool = false) -> Promise { - var request = URLRequest(url: URL(string: url)!) - request.httpMethod = verb.rawValue + + let urlSession: URLSession = (useSeedNodeURLSession ? seedNodeURLSession : snodeURLSession) + var request = URLRequest(url: url) + request.httpMethod = method.rawValue request.httpBody = body request.timeoutInterval = timeout request.allHTTPHeaderFields?.removeValue(forKey: "User-Agent") request.setValue("WhatsApp", forHTTPHeaderField: "User-Agent") // Set a fake value request.setValue("en-us", forHTTPHeaderField: "Accept-Language") // Set a fake value - let (promise, seal) = Promise.pending() - let urlSession = useSeedNodeURLSession ? seedNodeURLSession : snodeURLSession - let task = urlSession.dataTask(with: request) { data, response, error in - guard let data = data, let response = response as? HTTPURLResponse else { - if let error = error { - SNLog("\(verb.rawValue) request to \(url) failed due to error: \(error).") - } else { - SNLog("\(verb.rawValue) request to \(url) failed.") - } - - // Override the actual error so that we can correctly catch failed requests in sendOnionRequest(invoking:on:with:) - switch (error as? NSError)?.code { - case NSURLErrorTimedOut: return seal.reject(Error.timeout) - default: return seal.reject(Error.httpRequestFailed(statusCode: 0, data: nil)) + + return urlSession + .dataTaskPublisher(for: request) + .mapError { error in + SNLog("\(method.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:) + switch (error as NSError).code { + case NSURLErrorTimedOut: return HTTPError.timeout + default: return HTTPError.httpRequestFailed(statusCode: 0, data: nil) + } + } + .flatMap { data, response in + guard let response = response as? HTTPURLResponse else { + SNLog("\(method.rawValue) request to \(url) failed.") + return Fail(error: HTTPError.httpRequestFailed(statusCode: 0, data: data)) + .eraseToAnyPublisher() + } + let statusCode = UInt(response.statusCode) + // TODO: Remove all the JSON handling? + guard 200...299 ~= statusCode else { + 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("\(method.rawValue) request to \(url) failed with status code: \(statusCode) (\(jsonDescription)).") + return Fail(error: HTTPError.httpRequestFailed(statusCode: statusCode, data: data)) + .eraseToAnyPublisher() } + return Just(data) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - 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, data: data)) - } - let statusCode = UInt(response.statusCode) - - guard 200...299 ~= statusCode else { - var json: JSON? = nil - if let processedJson: JSON = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON { - json = processedJson - } - else if let result: String = String(data: data, encoding: .utf8) { - json = [ "result": result ] - } - - let jsonDescription: String = (json?.prettifiedDescription ?? "no debugging info provided") - SNLog("\(verb.rawValue) request to \(url) failed with status code: \(statusCode) (\(jsonDescription)).") - return seal.reject(Error.httpRequestFailed(statusCode: statusCode, data: data)) - } - - seal.fulfill(data) - } - task.resume() - return promise + .eraseToAnyPublisher() } } diff --git a/SessionUtilitiesKit/Networking/HTTPError.swift b/SessionUtilitiesKit/Networking/HTTPError.swift new file mode 100644 index 000000000..7a3d2af08 --- /dev/null +++ b/SessionUtilitiesKit/Networking/HTTPError.swift @@ -0,0 +1,26 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum HTTPError: LocalizedError, Equatable { + case generic + 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 .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." + } + } +} diff --git a/SessionUtilitiesKit/Networking/HTTPHeader.swift b/SessionUtilitiesKit/Networking/HTTPHeader.swift new file mode 100644 index 000000000..1ace5ed88 --- /dev/null +++ b/SessionUtilitiesKit/Networking/HTTPHeader.swift @@ -0,0 +1,19 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public typealias HTTPHeader = String + +public extension HTTPHeader { + static let authorization: HTTPHeader = "Authorization" + static let contentType: HTTPHeader = "Content-Type" + static let contentDisposition: HTTPHeader = "Content-Disposition" +} + +// MARK: - Convenience + +public extension Dictionary where Key == HTTPHeader, Value == String { + func toHTTPHeaders() -> [String: String] { + return self.reduce(into: [:]) { result, next in result[next.key] = next.value } + } +} diff --git a/SessionUtilitiesKit/Networking/HTTPMethod.swift b/SessionUtilitiesKit/Networking/HTTPMethod.swift new file mode 100644 index 000000000..940ca4fe3 --- /dev/null +++ b/SessionUtilitiesKit/Networking/HTTPMethod.swift @@ -0,0 +1,10 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum HTTPMethod: String, Codable { + case get = "GET" + case put = "PUT" + case post = "POST" + case delete = "DELETE" +} diff --git a/SessionUtilitiesKit/Networking/HTTPQueryParam.swift b/SessionUtilitiesKit/Networking/HTTPQueryParam.swift new file mode 100644 index 000000000..a766bf1ed --- /dev/null +++ b/SessionUtilitiesKit/Networking/HTTPQueryParam.swift @@ -0,0 +1,5 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public typealias HTTPQueryParam = String diff --git a/SessionUtilitiesKit/Networking/ProxiedContentDownloader.swift b/SessionUtilitiesKit/Networking/ProxiedContentDownloader.swift index 326760296..186262e49 100644 --- a/SessionUtilitiesKit/Networking/ProxiedContentDownloader.swift +++ b/SessionUtilitiesKit/Networking/ProxiedContentDownloader.swift @@ -3,6 +3,7 @@ // import Foundation +import Combine import ObjectiveC // Stills should be loaded before full GIFs. @@ -496,6 +497,42 @@ open class ProxiedContentDownloader: NSObject, URLSessionTaskDelegate, URLSessio processRequestQueueAsync() return assetRequest } + + public func requestAsset( + assetDescription: ProxiedContentAssetDescription, + priority: ProxiedContentRequestPriority, + shouldIgnoreSignalProxy: Bool = false + ) -> AnyPublisher<(ProxiedContentAsset, ProxiedContentAssetRequest?), Error> { + if let asset = assetMap.get(key: assetDescription.url) { + // Synchronous cache hit. + return Just((asset, nil)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + // Cache miss. + // + // Asset requests are done queued and performed asynchronously. + return Deferred { + Future { [weak self] resolver in + let assetRequest = ProxiedContentAssetRequest( + assetDescription: assetDescription, + priority: priority, + success: { request, asset in resolver(Result.success((asset, request))) }, + failure: { request in + resolver(Result.failure(HTTPError.generic)) + } + ) + assetRequest.shouldIgnoreSignalProxy = shouldIgnoreSignalProxy + self?.assetRequestQueue.append(assetRequest) + // Process the queue (which may start this request) + // asynchronously so that the caller has time to store + // a reference to the asset request returned by this + // method before its success/failure handler is called. + self?.processRequestQueueAsync() + } + }.eraseToAnyPublisher() + } public func cancelAllRequests() { self.assetRequestQueue.forEach { $0.cancel() } diff --git a/SessionMessagingKit/Common Networking/Request.swift b/SessionUtilitiesKit/Networking/Request.swift similarity index 70% rename from SessionMessagingKit/Common Networking/Request.swift rename to SessionUtilitiesKit/Networking/Request.swift index f32620bb2..943f2a1fa 100644 --- a/SessionMessagingKit/Common Networking/Request.swift +++ b/SessionUtilitiesKit/Networking/Request.swift @@ -1,39 +1,40 @@ import Foundation -import SessionUtilitiesKit // MARK: - Convenience Types -struct Empty: Codable {} +public struct Empty: Codable { + public init() {} +} -typealias NoBody = Empty -typealias NoResponse = Empty +public typealias NoBody = Empty +public typealias NoResponse = Empty -protocol EndpointType: Hashable { +public 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] +public struct Request { + public let method: HTTPMethod + public let server: String + public let endpoint: Endpoint + public let queryParameters: [HTTPQueryParam: String] + public let headers: [HTTPHeader: 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? + public let body: T? // MARK: - Initialization - init( - method: HTTP.Verb = .get, + public init( + method: HTTPMethod = .get, server: String, endpoint: Endpoint, - queryParameters: [QueryParam: String] = [:], - headers: [Header: String] = [:], + queryParameters: [HTTPQueryParam: String] = [:], + headers: [HTTPHeader: String] = [:], body: T? = nil ) { self.method = method @@ -56,7 +57,9 @@ struct Request { 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 } + guard let encodedData: Data = Data(base64Encoded: bodyString) else { + throw HTTPError.parsingFailed + } return encodedData @@ -73,11 +76,11 @@ struct Request { // MARK: - Request Generation - var urlPathAndParamsString: String { + public var urlPathAndParamsString: String { return [ "/\(endpoint.path)", queryParameters - .map { key, value in "\(key.rawValue)=\(value)" } + .map { key, value in "\(key)=\(value)" } .joined(separator: "&") ] .compactMap { $0 } @@ -85,8 +88,8 @@ struct Request { .joined(separator: "?") } - func generateUrlRequest() throws -> URLRequest { - guard let url: URL = url else { throw HTTP.Error.invalidURL } + public func generateUrlRequest() throws -> URLRequest { + guard let url: URL = url else { throw HTTPError.invalidURL } var urlRequest: URLRequest = URLRequest(url: url) urlRequest.httpMethod = method.rawValue diff --git a/SessionUtilitiesKit/Networking/RequestInfo.swift b/SessionUtilitiesKit/Networking/RequestInfo.swift new file mode 100644 index 000000000..156cd54da --- /dev/null +++ b/SessionUtilitiesKit/Networking/RequestInfo.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension HTTP { + struct RequestInfo: Codable { + let method: String + let endpoint: String + let headers: [String: String] + + public init( + method: String, + endpoint: String, + headers: [String: String] + ) { + self.method = method + self.endpoint = endpoint + self.headers = headers + } + } +} diff --git a/SessionSnodeKit/Models/ResponseInfo.swift b/SessionUtilitiesKit/Networking/ResponseInfo.swift similarity index 72% rename from SessionSnodeKit/Models/ResponseInfo.swift rename to SessionUtilitiesKit/Networking/ResponseInfo.swift index 80e9b5f87..9d2508c0d 100644 --- a/SessionSnodeKit/Models/ResponseInfo.swift +++ b/SessionUtilitiesKit/Networking/ResponseInfo.swift @@ -2,13 +2,13 @@ import Foundation -public protocol OnionRequestResponseInfoType: Codable { +public protocol ResponseInfoType: Codable { var code: Int { get } var headers: [String: String] { get } } -extension OnionRequestAPI { - public struct ResponseInfo: OnionRequestResponseInfoType { +public extension HTTP { + struct ResponseInfo: ResponseInfoType { public let code: Int public let headers: [String: String] @@ -18,3 +18,4 @@ extension OnionRequestAPI { } } } + diff --git a/SessionUtilitiesKit/Networking/SessionNetwork.swift b/SessionUtilitiesKit/Networking/SessionNetwork.swift new file mode 100644 index 000000000..04969735d --- /dev/null +++ b/SessionUtilitiesKit/Networking/SessionNetwork.swift @@ -0,0 +1,3 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SessionUtilitiesKit/Networking/URLResponse+Utilities.swift b/SessionUtilitiesKit/Networking/URLResponse+Utilities.swift new file mode 100644 index 000000000..219f446b4 --- /dev/null +++ b/SessionUtilitiesKit/Networking/URLResponse+Utilities.swift @@ -0,0 +1,14 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public extension URLResponse { + var stringEncoding: String.Encoding? { + guard let encodingName = textEncodingName else { return nil } + + let encoding = CFStringConvertIANACharSetNameToEncoding(encodingName as CFString) + guard encoding != kCFStringEncodingInvalidId else { return nil } + + return String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(encoding)) + } +} diff --git a/SessionUtilitiesKit/PromiseKit/AnyPromise+Conversion.swift b/SessionUtilitiesKit/PromiseKit/AnyPromise+Conversion.swift deleted file mode 100644 index eccf1bd7b..000000000 --- a/SessionUtilitiesKit/PromiseKit/AnyPromise+Conversion.swift +++ /dev/null @@ -1,10 +0,0 @@ -import PromiseKit - -public extension AnyPromise { - - static func from(_ promise: Promise) -> AnyPromise { - let result = AnyPromise(promise) - result.retainUntilComplete() - return result - } -} diff --git a/SessionUtilitiesKit/PromiseKit/Promise+Delaying.swift b/SessionUtilitiesKit/PromiseKit/Promise+Delaying.swift deleted file mode 100644 index 02401a14e..000000000 --- a/SessionUtilitiesKit/PromiseKit/Promise+Delaying.swift +++ /dev/null @@ -1,14 +0,0 @@ -import PromiseKit - -/// Delay the execution of the promise constructed in `body` by `delay` seconds. -public func withDelay(_ delay: TimeInterval, completionQueue: DispatchQueue, body: @escaping () -> Promise) -> Promise { - let (promise, seal) = Promise.pending() - Timer.scheduledTimerOnMainThread(withTimeInterval: delay, repeats: false) { _ in - body().done(on: completionQueue) { - seal.fulfill($0) - }.catch(on: completionQueue) { - seal.reject($0) - } - } - return promise -} diff --git a/SessionUtilitiesKit/PromiseKit/Promise+Retaining.swift b/SessionUtilitiesKit/PromiseKit/Promise+Retaining.swift deleted file mode 100644 index cb7262521..000000000 --- a/SessionUtilitiesKit/PromiseKit/Promise+Retaining.swift +++ /dev/null @@ -1,45 +0,0 @@ -import PromiseKit - -public extension AnyPromise { - - @objc func retainUntilComplete() { - var retainCycle: AnyPromise? = self - _ = self.ensure { - assert(retainCycle != nil) - retainCycle = nil - } - } -} - -public extension PMKFinalizer { - - func retainUntilComplete() { - var retainCycle: PMKFinalizer? = self - self.finally { - assert(retainCycle != nil) - retainCycle = nil - } - } -} - -public extension Promise { - - func retainUntilComplete() { - var retainCycle: Promise? = self - _ = self.ensure { - assert(retainCycle != nil) - retainCycle = nil - } - } -} - -public extension Guarantee { - - func retainUntilComplete() { - var retainCycle: Guarantee? = self - _ = self.done { _ in - assert(retainCycle != nil) - retainCycle = nil - } - } -} diff --git a/SessionUtilitiesKit/PromiseKit/Promise+Retrying.swift b/SessionUtilitiesKit/PromiseKit/Promise+Retrying.swift deleted file mode 100644 index acf707586..000000000 --- a/SessionUtilitiesKit/PromiseKit/Promise+Retrying.swift +++ /dev/null @@ -1,14 +0,0 @@ -import PromiseKit - -/// Retry the promise constructed in `body` up to `maxRetryCount` times. -public func attempt(maxRetryCount: UInt, recoveringOn queue: DispatchQueue, body: @escaping () -> Promise) -> Promise { - var retryCount = 0 - func attempt() -> Promise { - return body().recover(on: queue) { error -> Promise in - guard retryCount < maxRetryCount else { throw error } - retryCount += 1 - return attempt() - } - } - return attempt() -} diff --git a/SessionUtilitiesKit/PromiseKit/Promise+Timeout.swift b/SessionUtilitiesKit/PromiseKit/Promise+Timeout.swift deleted file mode 100644 index e4b843df6..000000000 --- a/SessionUtilitiesKit/PromiseKit/Promise+Timeout.swift +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import PromiseKit - -public extension Promise { - - func timeout(seconds: TimeInterval, timeoutError: Error) -> Promise { - return Promise { seal in - after(seconds: seconds).done { - seal.reject(timeoutError) - } - self.done { result in - seal.fulfill(result) - }.catch { err in - seal.reject(err) - } - } - } -} diff --git a/SessionUtilitiesKit/Utilities/ARC4RandomNumberGenerator.swift b/SessionUtilitiesKit/Utilities/ARC4RandomNumberGenerator.swift new file mode 100644 index 000000000..355b48a5f --- /dev/null +++ b/SessionUtilitiesKit/Utilities/ARC4RandomNumberGenerator.swift @@ -0,0 +1,73 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// +// Note: This was taken from TensorFlow's Random: +// https://github.com/apple/swift/blob/bc8f9e61d333b8f7a625f74d48ef0b554726e349/stdlib/public/TensorFlow/Random.swift +// +// the complex approach is needed due to an issue with Swift's randomElement(using:) +// generation (see https://stackoverflow.com/a/64897775 for more info) + +import Foundation + +public struct ARC4RandomNumberGenerator: RandomNumberGenerator { + var state: [UInt8] = Array(0...255) + var iPos: UInt8 = 0 + var jPos: UInt8 = 0 + + public init(seed: T) { + self.init( + seed: (0..<(UInt64.bitWidth / UInt64.bitWidth)).map { index in + UInt8(truncatingIfNeeded: seed >> (UInt8.bitWidth * index)) + } + ) + } + + public init(seed: [UInt8]) { + precondition(seed.count > 0, "Length of seed must be positive") + precondition(seed.count <= 256, "Length of seed must be at most 256") + + // Note: Have to use a for loop instead of a 'forEach' otherwise + // it doesn't work properly (not sure why...) + var j: UInt8 = 0 + for i: UInt8 in 0...255 { + j &+= S(i) &+ seed[Int(i) % seed.count] + swapAt(i, j) + } + } + + /// Produce the next random UInt64 from the stream, and advance the internal state + public mutating func next() -> UInt64 { + // Note: Have to use a for loop instead of a 'forEach' otherwise + // it doesn't work properly (not sure why...) + var result: UInt64 = 0 + for _ in 0.. UInt8 { + return state[Int(index)] + } + + /// Helper to swap elements of the state + private mutating func swapAt(_ i: UInt8, _ j: UInt8) { + state.swapAt(Int(i), Int(j)) + } + + /// Generates the next byte in the keystream. + private mutating func nextByte() -> UInt8 { + iPos &+= 1 + jPos &+= S(iPos) + swapAt(iPos, jPos) + return S(S(iPos) &+ S(jPos)) + } +} + +public extension ARC4RandomNumberGenerator { + mutating func nextBytes(count: Int) -> [UInt8] { + (0.. + +#define noEscape __attribute__((noescape)) + +@interface CExceptionHelper: NSObject + ++ (BOOL)performSafely:(noEscape void(^)(void))tryBlock error:(__autoreleasing NSError **)error; + +@end + +#endif diff --git a/SessionUtilitiesKit/Utilities/CExceptionHelper.mm b/SessionUtilitiesKit/Utilities/CExceptionHelper.mm new file mode 100644 index 000000000..fac2e007e --- /dev/null +++ b/SessionUtilitiesKit/Utilities/CExceptionHelper.mm @@ -0,0 +1,36 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// +// This logic is not foolproof and may result in memory-leaks, when possible we should look to remove this +// and use the native C++ <-> Swift interoperability coming with Swift 5.9 +// +// This solution was sourced from the following link, for more information please refer to this thread: +// https://forums.swift.org/t/pitch-a-swift-representation-for-thrown-and-caught-exceptions/54583 + +#import "CExceptionHelper.h" +#include + +@implementation CExceptionHelper + ++ (BOOL)performSafely:(noEscape void(^)(void))tryBlock error:(__autoreleasing NSError **)error { + try { + tryBlock(); + return YES; + } + catch(NSException* e) { + *error = [[NSError alloc] initWithDomain:e.name code:-1 userInfo:e.userInfo]; + return NO; + } + catch (std::exception& e) { + NSString* what = [NSString stringWithUTF8String: e.what()]; + NSDictionary* userInfo = @{NSLocalizedDescriptionKey : what}; + *error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-2 userInfo:userInfo]; + return NO; + } + catch(...) { + NSDictionary* userInfo = @{NSLocalizedDescriptionKey:@"Other C++ exception"}; + *error = [[NSError alloc] initWithDomain:@"cpp_exception" code:-3 userInfo:userInfo]; + return NO; + } +} + +@end diff --git a/SessionUtilitiesKit/Utilities/CryptoType.swift b/SessionUtilitiesKit/Utilities/CryptoType.swift new file mode 100644 index 000000000..04969735d --- /dev/null +++ b/SessionUtilitiesKit/Utilities/CryptoType.swift @@ -0,0 +1,3 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation diff --git a/SessionUtilitiesKit/Utilities/OWSBackgroundTask.m b/SessionUtilitiesKit/Utilities/OWSBackgroundTask.m index 7602df9e6..9e4dc76b5 100644 --- a/SessionUtilitiesKit/Utilities/OWSBackgroundTask.m +++ b/SessionUtilitiesKit/Utilities/OWSBackgroundTask.m @@ -4,8 +4,8 @@ #import "OWSBackgroundTask.h" #import "AppContext.h" -#import #import +#import NS_ASSUME_NONNULL_BEGIN @@ -237,7 +237,7 @@ typedef NSNumber *OWSTaskId; // always be called on the main thread, so we use DispatchSyncMainThreadSafe() // to ensure that. We thereby ensure that we don't end the background task // until all of the completion blocks have completed. - DispatchSyncMainThreadSafe(^{ + [Threading dispatchSyncMainThreadSafe:^{ for (BackgroundTaskExpirationBlock expirationBlock in expirationMap.allValues) { expirationBlock(); } @@ -245,7 +245,7 @@ typedef NSNumber *OWSTaskId; // Apparently we need to "end" even expired background tasks. [CurrentAppContext() endBackgroundTask:backgroundTaskId]; } - }); + }]; } - (void)timerDidFire @@ -329,7 +329,7 @@ typedef NSNumber *OWSTaskId; { __weak typeof(self) weakSelf = self; self.taskId = [OWSBackgroundTaskManager.sharedManager addTaskWithExpirationBlock:^{ - DispatchMainThreadSafe(^{ + [Threading dispatchMainThreadSafe:^{ OWSBackgroundTask *strongSelf = weakSelf; if (!strongSelf) { return; @@ -353,7 +353,7 @@ typedef NSNumber *OWSTaskId; if (completionBlock) { completionBlock(BackgroundTaskState_Expired); } - }); + }]; }]; // If a background task could not be begun, call the completion block. @@ -368,9 +368,9 @@ typedef NSNumber *OWSTaskId; self.completionBlock = nil; } if (completionBlock) { - DispatchMainThreadSafe(^{ + [Threading dispatchMainThreadSafe:^{ completionBlock(BackgroundTaskState_CouldNotStart); - }); + }]; } } } @@ -393,11 +393,11 @@ typedef NSNumber *OWSTaskId; } // endBackgroundTask must be called on the main thread. - DispatchMainThreadSafe(^{ + [Threading dispatchMainThreadSafe:^{ if (completionBlock) { completionBlock(BackgroundTaskState_Cancelled); } - }); + }]; } - (void)endBackgroundTask @@ -418,11 +418,11 @@ typedef NSNumber *OWSTaskId; } // endBackgroundTask must be called on the main thread. - DispatchMainThreadSafe(^{ + [Threading dispatchMainThreadSafe:^{ if (completionBlock) { completionBlock(BackgroundTaskState_Success); } - }); + }]; } @end diff --git a/SessionUtilitiesKit/Utilities/Optional+Utilities.swift b/SessionUtilitiesKit/Utilities/Optional+Utilities.swift index ede1f4e36..b78cb6679 100644 --- a/SessionUtilitiesKit/Utilities/Optional+Utilities.swift +++ b/SessionUtilitiesKit/Utilities/Optional+Utilities.swift @@ -20,6 +20,11 @@ extension Optional { public func defaulting(to value: Wrapped) -> Wrapped { return (self ?? value) } + + public mutating func setting(to value: Wrapped) -> Wrapped { + self = value + return value + } } extension Optional where Wrapped == String { diff --git a/SessionUtilitiesKit/Utilities/Randomness.swift b/SessionUtilitiesKit/Utilities/Randomness.swift new file mode 100644 index 000000000..6b6de608a --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Randomness.swift @@ -0,0 +1,21 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public enum Randomness { + /// Returns `size` bytes of random data generated using the default secure random number generator. See + /// [SecRandomCopyBytes](https://developer.apple.com/documentation/security/1399291-secrandomcopybytes) for more information. + public static func generateRandomBytes(numberBytes: Int) throws -> Data { + var randomBytes: Data = Data(count: numberBytes) + let result = randomBytes.withUnsafeMutableBytes { + SecRandomCopyBytes(kSecRandomDefault, numberBytes, $0.baseAddress!) + } + + guard result == errSecSuccess, randomBytes.count == numberBytes else { + print("Problem generating random bytes") + throw GeneralError.randomGenerationFailed + } + + return randomBytes + } +} diff --git a/SessionUtilitiesKit/Utilities/Threading.swift b/SessionUtilitiesKit/Utilities/Threading.swift new file mode 100644 index 000000000..e894bbf3c --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Threading.swift @@ -0,0 +1,23 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +@objc public class Threading: NSObject { + @objc public static func dispatchMainThreadSafe(_ closure: @escaping () -> ()) { + guard Thread.isMainThread else { + DispatchQueue.main.async { dispatchMainThreadSafe(closure) } + return + } + + closure() + } + + @objc public static func dispatchSyncMainThreadSafe(_ closure: @escaping () -> ()) { + guard Thread.isMainThread else { + DispatchQueue.main.sync { dispatchSyncMainThreadSafe(closure) } + return + } + + closure() + } +} diff --git a/SessionUtilitiesKit/Utilities/Version.swift b/SessionUtilitiesKit/Utilities/Version.swift new file mode 100644 index 000000000..dadc37c04 --- /dev/null +++ b/SessionUtilitiesKit/Utilities/Version.swift @@ -0,0 +1,55 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public struct Version: Comparable { + public let major: Int + public let minor: Int + public let patch: Int + + public var stringValue: String { "\(major).\(minor).\(patch)" } + + // MARK: - Initialization + + public init( + major: Int, + minor: Int, + patch: Int + ) { + self.major = major + self.minor = minor + self.patch = patch + } + + // MARK: - Functions + + public static func from(_ versionString: String) -> Version { + var tokens: [Int] = versionString + .split(separator: ".") + .map { (Int($0) ?? 0) } + + // Extend to '{major}.{minor}.{patch}' if any parts were omitted + while tokens.count < 3 { + tokens.append(0) + } + + return Version(major: tokens[0], minor: tokens[1], patch: tokens[2]) + } + + // MARK: - Comparable + + public static func == (lhs: Version, rhs: Version) -> Bool { + return ( + lhs.major == rhs.major && + lhs.minor == rhs.minor && + lhs.patch == rhs.patch + ) + } + + public static func < (lhs: Version, rhs: Version) -> Bool { + guard lhs.major == rhs.major else { return (lhs.major < rhs.major) } + guard lhs.minor == rhs.minor else { return (lhs.minor < rhs.minor) } + + return (lhs.patch < rhs.patch) + } +} diff --git a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift index 7f5ed9da6..7eeb39293 100644 --- a/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift +++ b/SessionUtilitiesKitTests/Database/Models/IdentitySpec.swift @@ -18,8 +18,8 @@ class IdentitySpec: QuickSpec { beforeEach { mockStorage = Storage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self ] ) } diff --git a/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift b/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift index fdb0d8069..e17536fe7 100644 --- a/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift +++ b/SessionUtilitiesKitTests/Database/Utilities/PersistableRecordUtilitiesSpec.swift @@ -84,6 +84,17 @@ class PersistableRecordUtilitiesSpec: QuickSpec { } } + private struct TestTarget: MigratableTarget { + static func migrations(_ db: Database) -> TargetMigrations { + return TargetMigrations( + identifier: .test, + migrations: (0..<100) + .map { _ in [] } + .appending([TestInsertTestTypeMigration.self]) + ) + } + } + // MARK: - Spec override func spec() { @@ -96,13 +107,8 @@ class PersistableRecordUtilitiesSpec: QuickSpec { PersistableRecordUtilitiesSpec.customWriter = customWriter mockStorage = Storage( customWriter: customWriter, - customMigrations: [ - TargetMigrations( - identifier: .test, - migrations: (0..<100) - .map { _ in [] } - .appending([TestInsertTestTypeMigration.self]) - ) + customMigrationTargets: [ + TestTarget.self ] ) } @@ -274,7 +280,7 @@ class PersistableRecordUtilitiesSpec: QuickSpec { it("succeeds when using the migration safe mutable upsert and the item does not already exist") { mockStorage.write { db in expect { - var result = MutableTestType(columnA: "Test14", columnB: "Test14B") + let result = MutableTestType(columnA: "Test14", columnB: "Test14B") try result.migrationSafeUpsert(db) return result } @@ -340,7 +346,8 @@ class PersistableRecordUtilitiesSpec: QuickSpec { beforeEach { var migrator: DatabaseMigrator = DatabaseMigrator() migrator.registerMigration( - TestAddColumnMigration.target, + mockStorage, + targetIdentifier: TestAddColumnMigration.target, migration: TestAddColumnMigration.self ) @@ -620,7 +627,7 @@ class PersistableRecordUtilitiesSpec: QuickSpec { it("succeeds when using the migration safe mutable upsert and the item does not already exist") { mockStorage.write { db in expect { - var result = MutableTestType(columnA: "Test21", columnB: "Test21B") + let result = MutableTestType(columnA: "Test21", columnB: "Test21B") try result.migrationSafeUpsert(db) return result } @@ -650,7 +657,7 @@ class PersistableRecordUtilitiesSpec: QuickSpec { sql: "INSERT INTO MutableTestType (columnA) VALUES (?)", arguments: StatementArguments(["Test23"]) ) - var result = MutableTestType(id: 1, columnA: "Test23", columnB: "Test23B") + let result = MutableTestType(id: 1, columnA: "Test23", columnB: "Test23B") try result.migrationSafeUpsert(db) return result } @@ -661,7 +668,7 @@ class PersistableRecordUtilitiesSpec: QuickSpec { sql: "INSERT INTO MutableTestType (columnA) VALUES (?)", arguments: StatementArguments(["Test24"]) ) - var result = MutableTestType(id: 2, columnA: "Test24", columnB: "Test24B") + let result = MutableTestType(id: 2, columnA: "Test24", columnB: "Test24B") try result.migrationSafeUpsert(db) return result.id } diff --git a/SessionUtilitiesKitTests/General/ArrayUtilitiesSpec.swift b/SessionUtilitiesKitTests/General/ArrayUtilitiesSpec.swift new file mode 100644 index 000000000..b2d9b74dd --- /dev/null +++ b/SessionUtilitiesKitTests/General/ArrayUtilitiesSpec.swift @@ -0,0 +1,86 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class ArrayUtilitiesSpec: QuickSpec { + private struct TestType: Equatable { + let stringValue: String + let intValue: Int + } + + // MARK: - Spec + + override func spec() { + describe("an Array") { + context("when grouping") { + it("maintains the original array ordering") { + let data: [TestType] = [ + TestType(stringValue: "b", intValue: 5), + TestType(stringValue: "A", intValue: 2), + TestType(stringValue: "z", intValue: 1), + TestType(stringValue: "x", intValue: 3), + TestType(stringValue: "7", intValue: 6), + TestType(stringValue: "A", intValue: 7), + TestType(stringValue: "z", intValue: 8), + TestType(stringValue: "7", intValue: 9), + TestType(stringValue: "7", intValue: 4), + TestType(stringValue: "h", intValue: 2), + TestType(stringValue: "z", intValue: 1), + TestType(stringValue: "m", intValue: 2) + ] + + let result1: [String: [TestType]] = data.grouped(by: \.stringValue) + let result2: [Int: [TestType]] = data.grouped(by: \.intValue) + + expect(result1).to(equal( + [ + "b": [TestType(stringValue: "b", intValue: 5)], + "A": [ + TestType(stringValue: "A", intValue: 2), + TestType(stringValue: "A", intValue: 7) + ], + "z": [ + TestType(stringValue: "z", intValue: 1), + TestType(stringValue: "z", intValue: 8), + TestType(stringValue: "z", intValue: 1) + ], + "x": [TestType(stringValue: "x", intValue: 3)], + "7": [ + TestType(stringValue: "7", intValue: 6), + TestType(stringValue: "7", intValue: 9), + TestType(stringValue: "7", intValue: 4) + ], + "h": [TestType(stringValue: "h", intValue: 2)], + "m": [TestType(stringValue: "m", intValue: 2)] + ] + )) + expect(result2).to(equal( + [ + 1: [ + TestType(stringValue: "z", intValue: 1), + TestType(stringValue: "z", intValue: 1), + ], + 2: [ + TestType(stringValue: "A", intValue: 2), + TestType(stringValue: "h", intValue: 2), + TestType(stringValue: "m", intValue: 2) + ], + 3: [TestType(stringValue: "x", intValue: 3)], + 4: [TestType(stringValue: "7", intValue: 4)], + 5: [TestType(stringValue: "b", intValue: 5)], + 6: [TestType(stringValue: "7", intValue: 6)], + 7: [TestType(stringValue: "A", intValue: 7)], + 9: [TestType(stringValue: "7", intValue: 9)], + 8: [TestType(stringValue: "z", intValue: 8)] + ] + )) + } + } + } + } +} diff --git a/SessionUtilitiesKitTests/General/SessionIdSpec.swift b/SessionUtilitiesKitTests/General/SessionIdSpec.swift index c3f22512a..62a76e33c 100644 --- a/SessionUtilitiesKitTests/General/SessionIdSpec.swift +++ b/SessionUtilitiesKitTests/General/SessionIdSpec.swift @@ -45,8 +45,10 @@ class SessionIdSpec: QuickSpec { .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) + expect(SessionId(.blinded15, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) .to(equal("1588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) + expect(SessionId(.blinded25, publicKey: Data(hex: TestConstants.publicKey).bytes).hexString) + .to(equal("2588672ccb97f40bb57238989226cf429b575ba355443f47bc76c5ab144a96c65b")) } } @@ -56,7 +58,8 @@ class SessionIdSpec: QuickSpec { 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)) + expect(SessionId.Prefix(from: "15")).to(equal(.blinded15)) + expect(SessionId.Prefix(from: "25")).to(equal(.blinded25)) } it("fails when nil") { diff --git a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift index b8c3591e4..1c8d502b2 100644 --- a/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift +++ b/SessionUtilitiesKitTests/JobRunner/JobRunnerSpec.swift @@ -36,7 +36,7 @@ class JobRunnerSpec: QuickSpec { } struct InvalidDetails: Codable { - func encode(to encoder: Encoder) throws { throw HTTP.Error.parsingFailed } + func encode(to encoder: Encoder) throws { throw HTTPError.parsingFailed } } enum TestJob: JobExecutor { @@ -109,8 +109,8 @@ class JobRunnerSpec: QuickSpec { beforeEach { mockStorage = Storage( customWriter: try! DatabaseQueue(), - customMigrations: [ - SNUtilitiesKit.migrations() + customMigrationTargets: [ + SNUtilitiesKit.self ] ) dependencies = Dependencies( @@ -295,7 +295,7 @@ class JobRunnerSpec: QuickSpec { context("by getting the details for jobs") { it("returns an empty dictionary when there are no jobs") { - expect(jobRunner.details()).to(equal([:])) + expect(jobRunner.allJobInfo()).to(equal([:])) } it("returns an empty dictionary when there are no jobs matching the filters") { @@ -310,7 +310,7 @@ class JobRunnerSpec: QuickSpec { ) } - expect(jobRunner.detailsFor(state: .running, variant: .messageSend)) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend)) .toEventually( equal([:]), timeout: .milliseconds(50) @@ -331,13 +331,23 @@ class JobRunnerSpec: QuickSpec { } // Wait for there to be data and the validate the filtering works - expect(jobRunner.details()) + expect(jobRunner.allJobInfo()) .toEventuallyNot( beEmpty(), timeout: .milliseconds(50) ) - expect(jobRunner.detailsFor(jobs: [job1])).to(equal([:])) - expect(jobRunner.detailsFor(jobs: [job2])).to(equal([101: job2.details])) + expect(jobRunner.jobInfoFor(jobs: [job1])).to(equal([:])) + expect(jobRunner.jobInfoFor(jobs: [job2])) + .to(equal( + [ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: job2.details + ) + ] + )) } it("can filter to running jobs") { @@ -387,12 +397,21 @@ class JobRunnerSpec: QuickSpec { } // Wait for there to be data and the validate the filtering works - expect(jobRunner.detailsFor(state: .running)) + expect(jobRunner.jobInfoFor(state: .running)) .toEventually( - equal([100: try! JSONEncoder().encode(TestDetails(completeTime: 1))]), + equal( + [ + 100: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) + ) + ] + ), timeout: .milliseconds(50) ) - expect(Array(jobRunner.details().keys).sorted()).to(equal([100, 101])) + expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) } it("can filter to pending jobs") { @@ -442,12 +461,21 @@ class JobRunnerSpec: QuickSpec { } // Wait for there to be data and the validate the filtering works - expect(jobRunner.detailsFor(state: .pending)) + expect(jobRunner.jobInfoFor(state: .pending)) .toEventually( - equal([101: try! JSONEncoder().encode(TestDetails(completeTime: 1))]), + equal( + [ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) + ) + ] + ), timeout: .milliseconds(50) ) - expect(Array(jobRunner.details().keys).sorted()).to(equal([100, 101])) + expect(Array(jobRunner.allJobInfo().keys).sorted()).to(equal([100, 101])) } it("can filter to specific variants") { @@ -475,12 +503,21 @@ class JobRunnerSpec: QuickSpec { } // Wait for there to be data and the validate the filtering works - expect(jobRunner.detailsFor(variant: .attachmentUpload)) + expect(jobRunner.jobInfoFor(variant: .attachmentUpload)) .toEventually( - equal([101: try! JSONEncoder().encode(TestDetails(completeTime: 2))]), + equal( + [ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 2)) + ) + ] + ), timeout: .milliseconds(50) ) - expect(Array(jobRunner.details().keys).sorted()) + expect(Array(jobRunner.allJobInfo().keys).sorted()) .toEventually( equal([100, 101]), timeout: .milliseconds(50) @@ -500,9 +537,19 @@ class JobRunnerSpec: QuickSpec { ) } - expect(jobRunner.detailsFor(state: .running, variant: .attachmentUpload)) + expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload)) .toEventually( - equal([101: try! JSONEncoder().encode(TestDetails(completeTime: 1))]), + equal( + [ + 101: + JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) + ) + ] + ), timeout: .milliseconds(50) ) } @@ -534,9 +581,18 @@ class JobRunnerSpec: QuickSpec { jobRunner.appDidFinishLaunching(dependencies: dependencies) - expect(jobRunner.detailsFor(state: .running, variant: .attachmentUpload)) + expect(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload)) .toEventually( - equal([101: try! JSONEncoder().encode(TestDetails(completeTime: 1))]), + equal( + [ + 101: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder().encode(TestDetails(completeTime: 1)) + ) + ] + ), timeout: .milliseconds(50) ) } @@ -580,7 +636,7 @@ class JobRunnerSpec: QuickSpec { ) } - expect(Array(jobRunner.detailsFor(state: .pending, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .pending, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) @@ -602,7 +658,7 @@ class JobRunnerSpec: QuickSpec { ) } - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) @@ -638,7 +694,7 @@ class JobRunnerSpec: QuickSpec { jobRunner.appDidFinishLaunching(dependencies: dependencies) - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) @@ -660,7 +716,7 @@ class JobRunnerSpec: QuickSpec { ) } - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) @@ -1071,13 +1127,13 @@ class JobRunnerSpec: QuickSpec { // Wait for 10ms to give the job the chance to be added Thread.sleep(forTimeInterval: 0.01) - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .to(beEmpty()) } // Wait for 10ms for the job to actually be added Thread.sleep(forTimeInterval: 0.01) - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .to(equal([100])) } } @@ -1097,7 +1153,7 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) @@ -1117,12 +1173,12 @@ class JobRunnerSpec: QuickSpec { } // Make sure the initial job is removed from the queue - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) ) - expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) } it("starts the initial job when the dependencies succeed") { @@ -1138,16 +1194,16 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) ) - expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure the initial job starts dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running, variant: .messageSend).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) @@ -1167,16 +1223,16 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) ) - expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1196,16 +1252,16 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) ) - expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1225,16 +1281,16 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) ) - expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1260,16 +1316,16 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( equal([101]), timeout: .milliseconds(50) ) - expect(jobRunner.detailsFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) + expect(jobRunner.jobInfoFor(state: .running, variant: .messageSend).keys).toNot(contain(100)) // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running, variant: .attachmentUpload).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running, variant: .attachmentUpload).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1301,7 +1357,7 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) @@ -1309,7 +1365,7 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1326,7 +1382,7 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) @@ -1334,7 +1390,7 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1358,7 +1414,7 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) @@ -1366,7 +1422,7 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(jobRunner.detailsFor(state: .running)) + expect(jobRunner.jobInfoFor(state: .running)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1385,7 +1441,7 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) @@ -1393,7 +1449,7 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(jobRunner.detailsFor(state: .running)) + expect(jobRunner.jobInfoFor(state: .running)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1413,13 +1469,13 @@ class JobRunnerSpec: QuickSpec { } // Make sure it runs - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) ) dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1429,13 +1485,25 @@ class JobRunnerSpec: QuickSpec { dependencies.fixedTime = 2 // Make sure it finishes once - expect(jobRunner.detailsFor(state: .running)) + expect(jobRunner.jobInfoFor(state: .running)) .toEventually( - equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 3))]), + equal( + [ + 100: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder().encode(TestDetails( + result: .deferred, + completeTime: 3 + )) + ) + ] + ), timeout: .milliseconds(50) ) dependencies.fixedTime = 3 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1445,13 +1513,25 @@ class JobRunnerSpec: QuickSpec { dependencies.fixedTime = 4 // Make sure it finishes twice - expect(jobRunner.detailsFor(state: .running)) + expect(jobRunner.jobInfoFor(state: .running)) .toEventually( - equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 5))]), + equal( + [ + 100: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder().encode(TestDetails( + result: .deferred, + completeTime: 5 + )) + ) + ] + ), timeout: .milliseconds(50) ) dependencies.fixedTime = 5 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1461,13 +1541,25 @@ class JobRunnerSpec: QuickSpec { dependencies.fixedTime = 6 // Make sure it's finishes the last time - expect(jobRunner.detailsFor(state: .running)) + expect(jobRunner.jobInfoFor(state: .running)) .toEventually( - equal([100: try! JSONEncoder().encode(TestDetails(result: .deferred, completeTime: 7))]), + equal( + [ + 100: JobRunner.JobInfo( + variant: .attachmentUpload, + threadId: nil, + interactionId: nil, + detailsData: try! JSONEncoder().encode(TestDetails( + result: .deferred, + completeTime: 7 + )) + ) + ] + ), timeout: .milliseconds(50) ) dependencies.fixedTime = 7 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1491,7 +1583,7 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) @@ -1499,7 +1591,7 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1516,7 +1608,7 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) @@ -1524,7 +1616,7 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1548,7 +1640,7 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) @@ -1556,7 +1648,7 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) @@ -1573,7 +1665,7 @@ class JobRunnerSpec: QuickSpec { } // Make sure the dependency is run - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( equal([100]), timeout: .milliseconds(50) @@ -1581,7 +1673,7 @@ class JobRunnerSpec: QuickSpec { // Make sure there are no running jobs dependencies.fixedTime = 1 - expect(Array(jobRunner.detailsFor(state: .running).keys)) + expect(Array(jobRunner.jobInfoFor(state: .running).keys)) .toEventually( beEmpty(), timeout: .milliseconds(50) diff --git a/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift b/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift new file mode 100644 index 000000000..1030e6900 --- /dev/null +++ b/SessionUtilitiesKitTests/Networking/BatchResponseSpec.swift @@ -0,0 +1,282 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class BatchResponseSpec: QuickSpec { + struct TestType: Codable, Equatable { + let stringValue: String + } + struct TestType2: Codable, Equatable { + let intValue: Int + let stringValue2: String + } + + // MARK: - Spec + + override func spec() { + // MARK: - HTTP.BatchSubResponse + + describe("an HTTP.BatchSubResponse") { + context("when decoding") { + it("decodes correctly") { + let jsonString: String = """ + { + "code": 200, + "headers": { + "testKey": "testValue" + }, + "body": { + "stringValue": "testValue" + } + } + """ + let subResponse: HTTP.BatchSubResponse? = try? JSONDecoder().decode( + HTTP.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: HTTP.BatchSubResponse? = try? JSONDecoder().decode( + HTTP.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: HTTP.BatchSubResponse? = try? JSONDecoder().decode( + HTTP.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: HTTP.BatchSubResponse? = try? JSONDecoder().decode( + HTTP.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: HTTP.BatchSubResponse? = try? JSONDecoder().decode( + HTTP.BatchSubResponse.self, + from: jsonString.data(using: .utf8)! + ) + + expect(subResponse).toNot(beNil()) + expect(subResponse?.body).to(beNil()) + expect(subResponse?.failedToParseBody).to(beFalse()) + } + } + } + + // 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: - --Combine + + describe("a (ResponseInfoType, Data?) Publisher") { + var responseInfo: ResponseInfoType! + var testType: TestType! + var testType2: TestType2! + var data: Data! + + beforeEach { + responseInfo = HTTP.ResponseInfo(code: 200, headers: [:]) + testType = TestType(stringValue: "test1") + testType2 = TestType2(intValue: 123, stringValue2: "test2") + data = """ + [\([ + try! JSONEncoder().encode( + HTTP.BatchSubResponse( + code: 200, + headers: [:], + body: testType, + failedToParseBody: false + ) + ), + try! JSONEncoder().encode( + HTTP.BatchSubResponse( + code: 200, + headers: [:], + body: testType2, + failedToParseBody: false + ) + ) + ] + .map { String(data: $0, encoding: .utf8)! } + .joined(separator: ","))] + """.data(using: .utf8)! + } + + it("decodes valid data correctly") { + var result: HTTP.BatchResponse? + Just((responseInfo, data)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .decoded(as: [ + HTTP.BatchSubResponse.self, + HTTP.BatchSubResponse.self + ]) + .sinkUntilComplete( + receiveValue: { result = $0 } + ) + + expect(result).toNot(beNil()) + expect((result?.responses[0] as? HTTP.BatchSubResponse)?.body) + .to(equal(testType)) + expect((result?.responses[1] as? HTTP.BatchSubResponse)?.body) + .to(equal(testType2)) + } + + it("fails if there is no data") { + var error: Error? + Just((responseInfo, nil)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .decoded(as: []) + .mapError { error.setting(to: $0) } + .sinkUntilComplete() + + expect(error?.localizedDescription) + .to(equal(HTTPError.parsingFailed.localizedDescription)) + } + + it("fails if the data is not JSON") { + var error: Error? + Just((responseInfo, Data([1, 2, 3]))) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .decoded(as: []) + .mapError { error.setting(to: $0) } + .sinkUntilComplete() + + expect(error?.localizedDescription) + .to(equal(HTTPError.parsingFailed.localizedDescription)) + } + + it("fails if the data is not a JSON array") { + var error: Error? + Just((responseInfo, "{}".data(using: .utf8))) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .decoded(as: []) + .mapError { error.setting(to: $0) } + .sinkUntilComplete() + + expect(error?.localizedDescription) + .to(equal(HTTPError.parsingFailed.localizedDescription)) + } + + it("fails if the JSON array does not have the same number of items as the expected types") { + var error: Error? + Just((responseInfo, data)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .decoded(as: [ + HTTP.BatchSubResponse.self, + HTTP.BatchSubResponse.self, + HTTP.BatchSubResponse.self + ]) + .mapError { error.setting(to: $0) } + .sinkUntilComplete() + + expect(error?.localizedDescription) + .to(equal(HTTPError.parsingFailed.localizedDescription)) + } + + it("fails if one of the JSON array values fails to decode") { + data = """ + [\([ + try! JSONEncoder().encode( + HTTP.BatchSubResponse( + code: 200, + headers: [:], + body: testType, + failedToParseBody: false + ) + ) + ] + .map { String(data: $0, encoding: .utf8)! } + .joined(separator: ",")),{"test": "test"}] + """.data(using: .utf8)! + + var error: Error? + Just((responseInfo, data)) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .decoded(as: [ + HTTP.BatchSubResponse.self, + HTTP.BatchSubResponse.self + ]) + .mapError { error.setting(to: $0) } + .sinkUntilComplete() + + expect(error?.localizedDescription) + .to(equal(HTTPError.parsingFailed.localizedDescription)) + } + } + } +} diff --git a/SessionMessagingKitTests/Common Networking/HeaderSpec.swift b/SessionUtilitiesKitTests/Networking/HeaderSpec.swift similarity index 64% rename from SessionMessagingKitTests/Common Networking/HeaderSpec.swift rename to SessionUtilitiesKitTests/Networking/HeaderSpec.swift index df47313ff..49d8fcc34 100644 --- a/SessionMessagingKitTests/Common Networking/HeaderSpec.swift +++ b/SessionUtilitiesKitTests/Networking/HeaderSpec.swift @@ -1,11 +1,12 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import SessionUtilitiesKit import Quick import Nimble -@testable import SessionMessagingKit +@testable import SessionUtilitiesKit class HeaderSpec: QuickSpec { // MARK: - Spec @@ -13,7 +14,8 @@ class HeaderSpec: QuickSpec { 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"])) + expect([HTTPHeader.authorization: "test"].toHTTPHeaders()) + .to(equal(["Authorization": "test"])) } } } diff --git a/SessionMessagingKitTests/Common Networking/RequestSpec.swift b/SessionUtilitiesKitTests/Networking/RequestSpec.swift similarity index 74% rename from SessionMessagingKitTests/Common Networking/RequestSpec.swift rename to SessionUtilitiesKitTests/Networking/RequestSpec.swift index 44d87d22d..d8eb0d738 100644 --- a/SessionMessagingKitTests/Common Networking/RequestSpec.swift +++ b/SessionUtilitiesKitTests/Networking/RequestSpec.swift @@ -4,11 +4,22 @@ import Foundation import Quick import Nimble -import SessionUtilitiesKit -@testable import SessionMessagingKit +@testable import SessionUtilitiesKit class RequestSpec: QuickSpec { + enum TestEndpoint: EndpointType { + case test1 + case testParams(String, Int) + + var path: String { + switch self { + case .test1: return "test1" + case .testParams(let str, let int): return "testParams/\(str)/int/\(int)" + } + } + } + struct TestType: Codable, Equatable { let stringValue: String } @@ -18,9 +29,9 @@ class RequestSpec: QuickSpec { override func spec() { describe("a Request") { it("is initialized with the correct default values") { - let request: Request = Request( + let request: Request = Request( server: "testServer", - endpoint: .batch + endpoint: .test1 ) expect(request.method.rawValue).to(equal("GET")) @@ -31,42 +42,42 @@ class RequestSpec: QuickSpec { context("when generating a URL") { it("adds a leading forward slash to the endpoint path") { - let request: Request = Request( + let request: Request = Request( server: "testServer", - endpoint: .batch + endpoint: .test1 ) - expect(request.urlPathAndParamsString).to(equal("/batch")) + expect(request.urlPathAndParamsString).to(equal("/test1")) } it("creates a valid URL with no query parameters") { - let request: Request = Request( + let request: Request = Request( server: "testServer", - endpoint: .batch + endpoint: .test1 ) - expect(request.urlPathAndParamsString).to(equal("/batch")) + expect(request.urlPathAndParamsString).to(equal("/test1")) } it("creates a valid URL when query parameters are provided") { - let request: Request = Request( + let request: Request = Request( server: "testServer", - endpoint: .batch, + endpoint: .test1, queryParameters: [ .limit: "123" ] ) - expect(request.urlPathAndParamsString).to(equal("/batch?limit=123")) + expect(request.urlPathAndParamsString).to(equal("/test1?limit=123")) } } context("when generating a URLRequest") { it("sets all the values correctly") { - let request: Request = Request( + let request: Request = Request( method: .delete, server: "testServer", - endpoint: .batch, + endpoint: .test1, headers: [ .authorization: "test" ] @@ -79,22 +90,22 @@ class RequestSpec: QuickSpec { } it("throws an error if the URL is invalid") { - let request: Request = Request( + let request: Request = Request( server: "testServer", - endpoint: .roomPollInfo("!!%%", 123) + endpoint: .testParams("!!%%", 123) ) expect { try request.generateUrlRequest() } - .to(throwError(HTTP.Error.invalidURL)) + .to(throwError(HTTPError.invalidURL)) } context("with a base64 string body") { it("successfully encodes the body") { - let request: Request = Request( + let request: Request = Request( server: "testServer", - endpoint: .batch, + endpoint: .test1, body: "TestMessage".data(using: .utf8)!.base64EncodedString() ) @@ -106,24 +117,24 @@ class RequestSpec: QuickSpec { } it("throws an error if the body is not base64 encoded") { - let request: Request = Request( + let request: Request = Request( server: "testServer", - endpoint: .batch, + endpoint: .test1, body: "TestMessage" ) expect { try request.generateUrlRequest() } - .to(throwError(HTTP.Error.parsingFailed)) + .to(throwError(HTTPError.parsingFailed)) } } context("with a byte body") { it("successfully encodes the body") { - let request: Request<[UInt8], OpenGroupAPI.Endpoint> = Request( + let request: Request<[UInt8], TestEndpoint> = Request( server: "testServer", - endpoint: .batch, + endpoint: .test1, body: [1, 2, 3] ) @@ -135,9 +146,9 @@ class RequestSpec: QuickSpec { context("with a JSON body") { it("successfully encodes the body") { - let request: Request = Request( + let request: Request = Request( server: "testServer", - endpoint: .batch, + endpoint: .test1, body: TestType(stringValue: "test") ) @@ -151,9 +162,9 @@ class RequestSpec: QuickSpec { } it("successfully encodes no body") { - let request: Request = Request( + let request: Request = Request( server: "testServer", - endpoint: .batch, + endpoint: .test1, body: nil ) diff --git a/SessionUtilitiesKitTests/Utilities/VersionSpec.swift b/SessionUtilitiesKitTests/Utilities/VersionSpec.swift new file mode 100644 index 000000000..55c25ba4d --- /dev/null +++ b/SessionUtilitiesKitTests/Utilities/VersionSpec.swift @@ -0,0 +1,110 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +import Quick +import Nimble + +@testable import SessionUtilitiesKit + +class VersionSpec: QuickSpec { + // MARK: - Spec + + override func spec() { + describe("a Version") { + it("can be created from a string") { + let version: Version = Version.from("1.20.3") + + expect(version.major).to(equal(1)) + expect(version.minor).to(equal(20)) + expect(version.patch).to(equal(3)) + } + + it("correctly exposes a string value") { + let version: Version = Version(major: 1, minor: 20, patch: 3) + + expect(version.stringValue).to(equal("1.20.3")) + } + + context("when checking equality") { + it("returns true if the values match") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("1.0.0") + + expect(version1 == version2) + .to(beTrue()) + } + + it("returns false if the values do not match") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("1.0.1") + + expect(version1 == version2) + .to(beFalse()) + } + } + + context("when comparing versions") { + it("returns correctly for a simple major difference") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("2.0.0") + + expect(version1 < version2).to(beTrue()) + expect(version2 > version1).to(beTrue()) + } + + it("returns correctly for a complex major difference") { + let version1a: Version = Version.from("2.90.90") + let version2a: Version = Version.from("10.0.0") + let version1b: Version = Version.from("0.7.2") + let version2b: Version = Version.from("5.0.2") + + expect(version1a < version2a).to(beTrue()) + expect(version2a > version1a).to(beTrue()) + expect(version1b < version2b).to(beTrue()) + expect(version2b > version1b).to(beTrue()) + } + + it("returns correctly for a simple minor difference") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("1.1.0") + + expect(version1 < version2).to(beTrue()) + expect(version2 > version1).to(beTrue()) + } + + it("returns correctly for a complex minor difference") { + let version1a: Version = Version.from("90.2.90") + let version2a: Version = Version.from("90.10.0") + let version1b: Version = Version.from("2.0.7") + let version2b: Version = Version.from("2.5.0") + + expect(version1a < version2a).to(beTrue()) + expect(version2a > version1a).to(beTrue()) + expect(version1b < version2b).to(beTrue()) + expect(version2b > version1b).to(beTrue()) + } + + it("returns correctly for a simple patch difference") { + let version1: Version = Version.from("1.0.0") + let version2: Version = Version.from("1.0.1") + + expect(version1 < version2).to(beTrue()) + expect(version2 > version1).to(beTrue()) + } + + it("returns correctly for a complex patch difference") { + let version1a: Version = Version.from("90.90.2") + let version2a: Version = Version.from("90.90.10") + let version1b: Version = Version.from("2.5.0") + let version2b: Version = Version.from("2.5.7") + + expect(version1a < version2a).to(beTrue()) + expect(version2a > version1a).to(beTrue()) + expect(version1b < version2b).to(beTrue()) + expect(version2b > version1b).to(beTrue()) + } + } + } + } +} diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift index 16c9bf731..aa778f9e4 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalInputAccessoryView.swift @@ -3,6 +3,7 @@ import Foundation import UIKit import SessionUIKit +import SignalCoreKit protocol AttachmentApprovalInputAccessoryViewDelegate: AnyObject { func attachmentApprovalInputUpdateMediaRail() diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift index 9ea069caa..a8c34aec9 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentApprovalViewController.swift @@ -6,7 +6,6 @@ import Foundation import AVFoundation import MediaPlayer import CoreServices -import PromiseKit import SessionUIKit import SessionMessagingKit import SignalCoreKit diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift index 9c6eec082..4c3ad9f57 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionToolbar.swift @@ -1,10 +1,9 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation import UIKit import SessionUIKit +import SignalCoreKit protocol AttachmentCaptionToolbarDelegate: AnyObject { func attachmentCaptionToolbarDidEdit(_ attachmentCaptionToolbar: AttachmentCaptionToolbar) @@ -129,7 +128,7 @@ class AttachmentCaptionToolbar: UIView, UITextViewDelegate { textView.themeBackgroundColor = .clear textView.themeTintColor = .textPrimary - textView.font = UIFont.ows_dynamicTypeBody + textView.font = UIFont.preferredFont(forTextStyle: .body) textView.themeTextColor = .textPrimary textView.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift index 778c0bf13..aa6485845 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentItemCollection.swift @@ -1,10 +1,8 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation -import PromiseKit import SessionMessagingKit +import SignalCoreKit class AddMoreRailItem: GalleryRailItem { func buildRailItemView() -> UIView { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift index 8e4d3be2c..db65cd7e2 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentPrepViewController.swift @@ -4,6 +4,7 @@ import Foundation import UIKit import AVFoundation import SessionUIKit +import SignalCoreKit protocol AttachmentPrepViewControllerDelegate: AnyObject { func prepViewControllerUpdateNavigationBar() diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index 842fb68c6..53a29da81 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -1,10 +1,9 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation import UIKit import SessionUIKit +import SignalCoreKit import PureLayout // Coincides with Android's max text message length diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift index 733be4cbd..ff4dd9f60 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorBrushViewController.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SignalCoreKit @objc public protocol ImageEditorBrushViewControllerDelegate: AnyObject { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift index fdbfc3fce..e0acf8bba 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCanvasView.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SignalCoreKit public class EditorTextLayer: CATextLayer { let itemId: String diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorContents.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorContents.swift index e5ce28f6d..76eb079dc 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorContents.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorContents.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import UIKit +import SignalCoreKit // ImageEditorContents represents a snapshot of canvas // state. diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift index 8ac563f0b..ad415e5e6 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorCropViewController.swift @@ -1,11 +1,10 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import UIKit import SessionUIKit +import SignalCoreKit -public protocol ImageEditorCropViewControllerDelegate: class { +public protocol ImageEditorCropViewControllerDelegate: AnyObject { func cropDidComplete(transform: ImageEditorTransform) func cropDidCancel() } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift index b52629052..d2c8b062b 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorModel.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import UIKit +import SignalCoreKit // Used to represent undo/redo operations. // @@ -25,7 +24,7 @@ private class ImageEditorOperation: NSObject { // MARK: - @objc -public protocol ImageEditorModelObserver: class { +public protocol ImageEditorModelObserver: AnyObject { // Used for large changes to the model, when the entire // model should be reloaded. func imageEditorModelDidChange(before: ImageEditorContents, diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPaletteView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPaletteView.swift index a4b015925..0cd26364e 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPaletteView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPaletteView.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SignalCoreKit public protocol ImageEditorPaletteViewDelegate: AnyObject { func selectedColorDidChange() diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPanGestureRecognizer.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPanGestureRecognizer.swift index 699a2c832..919a343ca 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPanGestureRecognizer.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPanGestureRecognizer.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import UIKit +import SignalCoreKit // This GR: // diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift index c76f49475..01e580ecb 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorPinchGestureRecognizer.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import UIKit +import SignalCoreKit public struct ImageEditorPinchState { public let centroid: CGPoint diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift index 02bcab923..2e9e5b998 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorTextViewController.swift @@ -1,12 +1,11 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import UIKit import SessionUIKit +import SignalCoreKit @objc -public protocol VAlignTextViewDelegate: class { +public protocol VAlignTextViewDelegate: AnyObject { func textViewDidComplete() } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift index 1fdb229f2..ef9bb9c64 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Image Editing/ImageEditorView.swift @@ -1,9 +1,8 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import UIKit import SessionUtilitiesKit +import SignalCoreKit @objc public protocol ImageEditorViewDelegate: AnyObject { diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index f59f8357e..6089077cc 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -1,13 +1,13 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// -import Foundation +import UIKit +import Combine import MediaPlayer import YYImage import NVActivityIndicatorView import SessionUIKit import SessionMessagingKit +import SignalCoreKit public protocol MediaMessageViewAudioDelegate: AnyObject { func progressChanged(_ progressSeconds: CGFloat, durationSeconds: CGFloat) @@ -22,6 +22,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { // MARK: Properties + private var disposables: Set = Set() public let mode: Mode public let attachment: SignalAttachment @@ -259,15 +260,15 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { // Styling switch mode { case .attachmentApproval: - label.font = UIFont.ows_boldFont(withSize: ScaleFromIPhone5To7Plus(16, 22)) + label.font = UIFont.boldSystemFont(ofSize: ScaleFromIPhone5To7Plus(16, 22)) label.themeTextColor = .textPrimary case .large: - label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(18, 24)) + label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(18, 24)) label.themeTextColor = .primary case .small: - label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(14, 14)) + label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(14, 14)) label.themeTextColor = .primary } @@ -314,15 +315,15 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { // Styling switch mode { case .attachmentApproval: - label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(12, 18)) + label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(12, 18)) label.themeTextColor = .textSecondary case .large: - label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(18, 24)) + label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(18, 24)) label.themeTextColor = .primary case .small: - label.font = UIFont.ows_regularFont(withSize: ScaleFromIPhone5To7Plus(14, 14)) + label.font = UIFont.systemFont(ofSize: ScaleFromIPhone5To7Plus(14, 14)) label.themeTextColor = .primary } @@ -331,7 +332,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { // We only load Link Previews for HTTPS urls so append an explanation for not if let linkPreviewURL: String = linkPreviewInfo?.url { if let targetUrl: URL = URL(string: linkPreviewURL), targetUrl.scheme?.lowercased() != "https" { - label.font = UIFont.ows_regularFont(withSize: Values.verySmallFontSize) + label.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) label.text = "vc_share_link_previews_unsecure".localized() label.themeTextColor = (mode == .attachmentApproval ? .textSecondary : @@ -352,7 +353,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { // Format string for file size label in call interstitial view. // Embeds: {{file size as 'N mb' or 'N kb'}}. let fileSize: UInt = attachment.dataLength - label.text = String(format: "ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT".localized(), OWSFormat.formatFileSize(UInt(fileSize))) + label.text = String(format: "ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT".localized(), Format.fileSize(fileSize)) label.textAlignment = .center } @@ -566,44 +567,52 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { loadingView.startAnimating() LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL) - .done { [weak self] draft in - // TODO: Look at refactoring this behaviour to consolidate attachment mutations - self?.attachment.linkPreviewDraft = draft - self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) - - // Update the UI - self?.titleLabel.text = (draft.title ?? self?.titleLabel.text) - self?.loadingView.alpha = 0 - self?.loadingView.stopAnimating() - self?.imageView.alpha = 1 - - if let jpegImageData: Data = draft.jpegImageData, let loadedImage: UIImage = UIImage(data: jpegImageData) { - self?.imageView.image = loadedImage - self?.imageView.contentMode = .scaleAspectFill + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] result in + switch result { + case .finished: break + case .failure: + self?.loadingView.alpha = 0 + self?.loadingView.stopAnimating() + self?.imageView.alpha = 1 + self?.titleLabel.numberOfLines = 1 // Truncates the URL at 1 line so the error is more readable + self?.subtitleLabel.isHidden = false + + // Set the error text appropriately + if let targetUrl: URL = URL(string: linkPreviewURL), targetUrl.scheme?.lowercased() != "https" { + // This error case is handled already in the 'subtitleLabel' creation + } + else { + self?.subtitleLabel.font = UIFont.systemFont(ofSize: Values.verySmallFontSize) + self?.subtitleLabel.text = "vc_share_link_previews_error".localized() + self?.subtitleLabel.themeTextColor = (self?.mode == .attachmentApproval ? + .textSecondary : + .primary + ) + self?.subtitleLabel.textAlignment = .left + } + } + }, + receiveValue: { [weak self] draft in + // TODO: Look at refactoring this behaviour to consolidate attachment mutations + self?.attachment.linkPreviewDraft = draft + self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) + + // Update the UI + self?.titleLabel.text = (draft.title ?? self?.titleLabel.text) + self?.loadingView.alpha = 0 + self?.loadingView.stopAnimating() + self?.imageView.alpha = 1 + + if let jpegImageData: Data = draft.jpegImageData, let loadedImage: UIImage = UIImage(data: jpegImageData) { + self?.imageView.image = loadedImage + self?.imageView.contentMode = .scaleAspectFill + } } - } - .catch { [weak self] _ in - self?.loadingView.alpha = 0 - self?.loadingView.stopAnimating() - self?.imageView.alpha = 1 - self?.titleLabel.numberOfLines = 1 // Truncates the URL at 1 line so the error is more readable - self?.subtitleLabel.isHidden = false - - // Set the error text appropriately - if let targetUrl: URL = URL(string: linkPreviewURL), targetUrl.scheme?.lowercased() != "https" { - // This error case is handled already in the 'subtitleLabel' creation - } - else { - self?.subtitleLabel.font = UIFont.ows_regularFont(withSize: Values.verySmallFontSize) - self?.subtitleLabel.text = "vc_share_link_previews_error".localized() - self?.subtitleLabel.themeTextColor = (self?.mode == .attachmentApproval ? - .textSecondary : - .primary - ) - self?.subtitleLabel.textAlignment = .left - } - } - .retainUntilComplete() + ) + .store(in: &disposables) } // MARK: - Functions diff --git a/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift index c99513091..f24b12262 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/OWSVideoPlayer.swift @@ -1,10 +1,9 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation import AVFoundation import SessionMessagingKit +import SignalCoreKit public protocol OWSVideoPlayerDelegate: AnyObject { func videoPlayerDidPlayToCompletion(_ videoPlayer: OWSVideoPlayer) diff --git a/SignalUtilitiesKit/Media Viewing & Editing/VideoPlayerView.swift b/SignalUtilitiesKit/Media Viewing & Editing/VideoPlayerView.swift index bebeda88c..373de082e 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/VideoPlayerView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/VideoPlayerView.swift @@ -3,6 +3,7 @@ import UIKit import AVFoundation import SessionUIKit +import SignalCoreKit @objc public class VideoPlayerView: UIView { diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit-Prefix.pch b/SignalUtilitiesKit/Meta/SignalUtilitiesKit-Prefix.pch deleted file mode 100644 index 4ea96ba51..000000000 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit-Prefix.pch +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -#ifdef __OBJC__ - #import - #import - - @import PureLayout; - @import SignalCoreKit; - - @import SessionMessagingKit; - @import SessionSnodeKit; - @import SessionUtilitiesKit; -#endif diff --git a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h index 86f3cf646..b856fa9a0 100644 --- a/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h +++ b/SignalUtilitiesKit/Meta/SignalUtilitiesKit.h @@ -10,13 +10,8 @@ FOUNDATION_EXPORT const unsigned char SignalUtilitiesKitVersionString[]; #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 deleted file mode 100644 index 256438529..000000000 --- a/SignalUtilitiesKit/Profile Pictures/Identicon+ObjC.swift +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import SessionUIKit -import SessionUtilitiesKit - -@objc(LKIdenticon) -public final class Identicon: NSObject { - private static let placeholderCache: Atomic> = { - let result = NSCache() - result.countLimit = 50 - - return Atomic(result) - }() - - @objc public static func generatePlaceholderIcon(seed: String, text: String, size: CGFloat) -> UIImage { - let icon = PlaceholderIcon(seed: seed) - - 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 ? - initials.substring(to: 2).uppercased() : - content.substring(to: 2).uppercased() - ) - ) - - let rect = CGRect(origin: CGPoint.zero, size: layer.frame.size) - let renderer = UIGraphicsImageRenderer(size: rect.size) - let result = renderer.image { layer.render(in: $0.cgContext) } - - 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 deleted file mode 100644 index 1f87c047f..000000000 --- a/SignalUtilitiesKit/Profile Pictures/ProfilePictureView.swift +++ /dev/null @@ -1,299 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import UIKit -import GRDB -import YYImage -import SessionUIKit -import SessionMessagingKit - -public final class ProfilePictureView: UIView { - private var hasTappableProfilePicture: Bool = false - public var size: CGFloat = 0 - - // Constraints - private var imageViewWidthConstraint: NSLayoutConstraint! - private var imageViewHeightConstraint: NSLayoutConstraint! - private var additionalImageViewWidthConstraint: NSLayoutConstraint! - private var additionalImageViewHeightConstraint: NSLayoutConstraint! - - // MARK: - Components - - private lazy var imageContainerView: UIView = { - let result: UIView = UIView() - result.translatesAutoresizingMaskIntoConstraints = false - result.clipsToBounds = true - result.themeBackgroundColor = .backgroundSecondary - - return result - }() - - private lazy var imageView: UIImageView = { - let result: UIImageView = UIImageView() - result.translatesAutoresizingMaskIntoConstraints = false - result.contentMode = .scaleAspectFill - result.isHidden = true - - return result - }() - - private lazy var animatedImageView: YYAnimatedImageView = { - let result: YYAnimatedImageView = YYAnimatedImageView() - result.translatesAutoresizingMaskIntoConstraints = false - result.contentMode = .scaleAspectFill - result.isHidden = true - - return result - }() - - private lazy var additionalImageContainerView: UIView = { - let result: UIView = UIView() - result.translatesAutoresizingMaskIntoConstraints = false - result.clipsToBounds = true - result.themeBackgroundColor = .primary - result.themeBorderColor = .backgroundPrimary - result.layer.cornerRadius = (Values.smallProfilePictureSize / 2) - result.isHidden = true - - return result - }() - - private lazy var additionalProfilePlaceholderImageView: UIImageView = { - let result: UIImageView = UIImageView( - image: UIImage(systemName: "person.fill")?.withRenderingMode(.alwaysTemplate) - ) - result.translatesAutoresizingMaskIntoConstraints = false - result.contentMode = .scaleAspectFill - result.themeTintColor = .textPrimary - result.isHidden = true - - return result - }() - - private lazy var additionalImageView: UIImageView = { - let result: UIImageView = UIImageView() - result.translatesAutoresizingMaskIntoConstraints = false - result.contentMode = .scaleAspectFill - result.themeTintColor = .textPrimary - result.isHidden = true - - return result - }() - - private lazy var additionalAnimatedImageView: YYAnimatedImageView = { - let result: YYAnimatedImageView = YYAnimatedImageView() - result.translatesAutoresizingMaskIntoConstraints = false - result.contentMode = .scaleAspectFill - result.isHidden = true - - return result - }() - - // MARK: - Lifecycle - - public override init(frame: CGRect) { - super.init(frame: frame) - setUpViewHierarchy() - } - - public required init?(coder: NSCoder) { - super.init(coder: coder) - setUpViewHierarchy() - } - - private func setUpViewHierarchy() { - let imageViewSize = CGFloat(Values.mediumProfilePictureSize) - let additionalImageViewSize = CGFloat(Values.smallProfilePictureSize) - - addSubview(imageContainerView) - addSubview(additionalImageContainerView) - - imageContainerView.pin(.leading, to: .leading, of: self) - imageContainerView.pin(.top, to: .top, of: self) - imageViewWidthConstraint = imageContainerView.set(.width, to: imageViewSize) - imageViewHeightConstraint = imageContainerView.set(.height, to: imageViewSize) - additionalImageContainerView.pin(.trailing, to: .trailing, of: self) - additionalImageContainerView.pin(.bottom, to: .bottom, of: self) - additionalImageViewWidthConstraint = additionalImageContainerView.set(.width, to: additionalImageViewSize) - additionalImageViewHeightConstraint = additionalImageContainerView.set(.height, to: additionalImageViewSize) - - imageContainerView.addSubview(imageView) - imageContainerView.addSubview(animatedImageView) - additionalImageContainerView.addSubview(additionalImageView) - additionalImageContainerView.addSubview(additionalAnimatedImageView) - additionalImageContainerView.addSubview(additionalProfilePlaceholderImageView) - - imageView.pin(to: imageContainerView) - animatedImageView.pin(to: imageContainerView) - additionalImageView.pin(to: additionalImageContainerView) - additionalAnimatedImageView.pin(to: additionalImageContainerView) - - additionalProfilePlaceholderImageView.pin(.top, to: .top, of: additionalImageContainerView, withInset: 3) - additionalProfilePlaceholderImageView.pin(.left, to: .left, of: additionalImageContainerView) - additionalProfilePlaceholderImageView.pin(.right, to: .right, of: additionalImageContainerView) - additionalProfilePlaceholderImageView.pin(.bottom, to: .bottom, of: additionalImageContainerView, withInset: 5) - } - - public func update( - publicKey: String = "", - profile: Profile? = nil, - additionalProfile: Profile? = nil, - threadVariant: SessionThread.Variant, - openGroupProfilePictureData: Data? = nil, - useFallbackPicture: Bool = false, - showMultiAvatarForClosedGroup: Bool = false - ) { - AssertIsOnMainThread() - guard !useFallbackPicture else { - switch self.size { - case Values.smallProfilePictureSize.. (image: UIImage?, animatedImage: YYImage?, isTappable: Bool) { - if let profile: Profile = profile, let profileData: Data = ProfileManager.profileAvatar(profile: profile) { - let format: ImageFormat = profileData.guessedImageFormat - - let image: UIImage? = (format == .gif || format == .webp ? - nil : - UIImage(data: profileData) - ) - let animatedImage: YYImage? = (format != .gif && format != .webp ? - nil : - YYImage(data: profileData) - ) - - if image != nil || animatedImage != nil { - return (image, animatedImage, true) - } - } - - return ( - Identicon.generatePlaceholderIcon( - seed: publicKey, - text: (profile?.displayName(for: threadVariant)) - .defaulting(to: publicKey), - size: size - ), - nil, - 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 - additionalImageContainerView.isHidden = false - - if let additionalProfile: Profile = additionalProfile { - let (image, animatedImage, _): (UIImage?, YYImage?, Bool) = getProfilePicture( - of: targetSize, - for: additionalProfile.id, - profile: additionalProfile - ) - - // Set the images and show the appropriate imageView (non-animated should be - // visible if there is no image) - additionalImageView.image = image - additionalAnimatedImageView.image = animatedImage - additionalImageView.isHidden = (animatedImage != nil) - additionalAnimatedImageView.isHidden = (animatedImage == nil) - additionalProfilePlaceholderImageView.isHidden = true - } - else { - additionalImageView.isHidden = true - additionalAnimatedImageView.isHidden = true - additionalProfilePlaceholderImageView.isHidden = false - } - - default: - targetSize = self.size - imageViewWidthConstraint.constant = targetSize - imageViewHeightConstraint.constant = targetSize - additionalImageContainerView.isHidden = true - additionalImageView.image = nil - additionalImageView.isHidden = true - additionalAnimatedImageView.image = nil - additionalAnimatedImageView.isHidden = true - additionalProfilePlaceholderImageView.isHidden = true - } - - // Set the image - if let openGroupProfilePictureData: Data = openGroupProfilePictureData { - let format: ImageFormat = openGroupProfilePictureData.guessedImageFormat - - let image: UIImage? = (format == .gif || format == .webp ? - nil : - UIImage(data: openGroupProfilePictureData) - ) - let animatedImage: YYImage? = (format != .gif && format != .webp ? - nil : - YYImage(data: openGroupProfilePictureData) - ) - - imageView.image = image - animatedImageView.image = animatedImage - imageView.isHidden = (animatedImage != nil) - animatedImageView.isHidden = (animatedImage == nil) - hasTappableProfilePicture = true - } - else { - let (image, animatedImage, isTappable): (UIImage?, YYImage?, Bool) = getProfilePicture( - of: targetSize, - for: publicKey, - profile: profile - ) - imageView.image = image - animatedImageView.image = animatedImage - imageView.isHidden = (animatedImage != nil) - animatedImageView.isHidden = (animatedImage == nil) - hasTappableProfilePicture = isTappable - } - - imageView.contentMode = .scaleAspectFill - animatedImageView.contentMode = .scaleAspectFill - imageContainerView.themeBackgroundColor = .backgroundSecondary - imageContainerView.layer.cornerRadius = (targetSize / 2) - additionalImageContainerView.layer.cornerRadius = (targetSize / 2) - } - - // MARK: - Convenience - - @objc public func getProfilePicture() -> UIImage? { - return (hasTappableProfilePicture ? imageView.image : nil) - } -} diff --git a/SignalUtilitiesKit/Screen Lock/ScreenLock.swift b/SignalUtilitiesKit/Screen Lock/ScreenLock.swift index a4158d358..c1ed4654a 100644 --- a/SignalUtilitiesKit/Screen Lock/ScreenLock.swift +++ b/SignalUtilitiesKit/Screen Lock/ScreenLock.swift @@ -4,6 +4,7 @@ import Foundation import GRDB import LocalAuthentication import SessionMessagingKit +import SignalCoreKit public class ScreenLock { public enum Outcome { diff --git a/SignalUtilitiesKit/Screen Lock/ScreenLockViewController.swift b/SignalUtilitiesKit/Screen Lock/ScreenLockViewController.swift index cfd8d371b..2279321d2 100644 --- a/SignalUtilitiesKit/Screen Lock/ScreenLockViewController.swift +++ b/SignalUtilitiesKit/Screen Lock/ScreenLockViewController.swift @@ -63,7 +63,7 @@ open class ScreenLockViewController: UIViewController { open override func loadView() { super.loadView() - view.themeBackgroundColor = .black // Need to match the Launch screen + view.themeBackgroundColorForced = .theme(.classicDark, color: .black) // Need to match the Launch screen let edgesView: UIView = UIView.container() self.view.addSubview(edgesView) diff --git a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift index 0cb3d92ba..06ae06236 100644 --- a/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift +++ b/SignalUtilitiesKit/Shared View Controllers/ModalActivityIndicatorViewController.swift @@ -4,6 +4,7 @@ import Foundation import MediaPlayer import SessionUIKit import NVActivityIndicatorView +import SignalCoreKit // A modal view that be used during blocking interactions (e.g. waiting on response from // service or on the completion of a long-running local operation). diff --git a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m b/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m index 6d2ea863b..c35b43035 100644 --- a/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m +++ b/SignalUtilitiesKit/Shared View Controllers/OWSViewController.m @@ -4,8 +4,10 @@ #import "OWSViewController.h" #import "UIView+OWS.h" -#import #import "AppContext.h" +#import +#import +#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift b/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift index 67c745389..30d626382 100644 --- a/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift +++ b/SignalUtilitiesKit/Shared Views/ApprovalRailCellView.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SignalCoreKit protocol ApprovalRailCellViewDelegate: AnyObject { func approvalRailCellView(_ approvalRailCellView: ApprovalRailCellView, didRemoveItem attachmentItem: SignalAttachmentItem) diff --git a/SignalUtilitiesKit/Shared Views/CircleView.swift b/SignalUtilitiesKit/Shared Views/CircleView.swift index 3cec26ba5..957f17da7 100644 --- a/SignalUtilitiesKit/Shared Views/CircleView.swift +++ b/SignalUtilitiesKit/Shared Views/CircleView.swift @@ -1,7 +1,7 @@ -// // Copyright (c) 2020 Open Whisper Systems. All rights reserved. -// + import UIKit +import SignalCoreKit @objc (OWSCircleView) public class CircleView: UIView { diff --git a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift index c41ccb715..11ce30b7d 100644 --- a/SignalUtilitiesKit/Shared Views/GalleryRailView.swift +++ b/SignalUtilitiesKit/Shared Views/GalleryRailView.swift @@ -1,7 +1,6 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit -import PromiseKit import SessionUIKit // MARK: - GalleryRailItem diff --git a/SignalUtilitiesKit/Shared Views/TappableStackView.swift b/SignalUtilitiesKit/Shared Views/TappableStackView.swift index 27d66af81..cd5af8000 100644 --- a/SignalUtilitiesKit/Shared Views/TappableStackView.swift +++ b/SignalUtilitiesKit/Shared Views/TappableStackView.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// import Foundation +import SignalCoreKit @objc public class TappableStackView: UIStackView { diff --git a/SignalUtilitiesKit/Shared Views/TappableView.swift b/SignalUtilitiesKit/Shared Views/TappableView.swift index 585410e45..d08a04ca5 100644 --- a/SignalUtilitiesKit/Shared Views/TappableView.swift +++ b/SignalUtilitiesKit/Shared Views/TappableView.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// import Foundation +import SignalCoreKit public class TappableView: UIView { let actionBlock : (() -> Void) diff --git a/SignalUtilitiesKit/Shared Views/Toast.swift b/SignalUtilitiesKit/Shared Views/Toast.swift index 231dd800b..591842fe0 100644 --- a/SignalUtilitiesKit/Shared Views/Toast.swift +++ b/SignalUtilitiesKit/Shared Views/Toast.swift @@ -2,6 +2,7 @@ import UIKit import SessionUIKit +import SignalCoreKit public class ToastController: ToastViewDelegate { static var currentToastController: ToastController? diff --git a/SignalUtilitiesKit/Utilities/AppSetup.swift b/SignalUtilitiesKit/Utilities/AppSetup.swift index cec53720e..58f0c6856 100644 --- a/SignalUtilitiesKit/Utilities/AppSetup.swift +++ b/SignalUtilitiesKit/Utilities/AppSetup.swift @@ -7,16 +7,16 @@ import SessionUtilitiesKit import SessionUIKit public enum AppSetup { - private static var hasRun: Bool = false + private static let hasRun: Atomic = Atomic(false) public static func setupEnvironment( appSpecificBlock: @escaping () -> (), migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, - migrationsCompletion: @escaping (Result, Bool) -> () + migrationsCompletion: @escaping (Result, Bool) -> () ) { - guard !AppSetup.hasRun else { return } + guard !AppSetup.hasRun.wrappedValue else { return } - AppSetup.hasRun = true + AppSetup.hasRun.mutate { $0 = true } var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(labelStr: #function) @@ -27,7 +27,7 @@ public enum AppSetup { // initializers injected. OWSBackgroundTaskManager.shared().observeNotifications() - // AFNetworking (via CFNetworking) spools it's attachments to NSTemporaryDirectory(). + // Attachments can be stored 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( @@ -61,25 +61,39 @@ public enum AppSetup { public static func runPostSetupMigrations( backgroundTask: OWSBackgroundTask? = nil, migrationProgressChanged: ((CGFloat, TimeInterval) -> ())? = nil, - migrationsCompletion: @escaping (Result, Bool) -> () + migrationsCompletion: @escaping (Result, Bool) -> () ) { var backgroundTask: OWSBackgroundTask? = (backgroundTask ?? OWSBackgroundTask(labelStr: #function)) Storage.shared.perform( - migrations: [ - SNUtilitiesKit.migrations(), - SNSnodeKit.migrations(), - SNMessagingKit.migrations(), - SNUIKit.migrations() + migrationTargets: [ + SNUtilitiesKit.self, + SNSnodeKit.self, + SNMessagingKit.self, + SNUIKit.self ], onProgressUpdate: migrationProgressChanged, onComplete: { result, needsConfigSync in - DispatchQueue.main.async { - migrationsCompletion(result, needsConfigSync) - - // The 'if' is only there to prevent the "variable never read" warning from showing - if backgroundTask != nil { backgroundTask = nil } + // After the migrations have run but before the migration completion we load the + // SessionUtil state and update the 'needsConfigSync' flag based on whether the + // configs also need to be sync'ed + if Identity.userExists() { + SessionUtil.loadState( + userPublicKey: getUserHexEncodedPublicKey(), + ed25519SecretKey: Identity.fetchUserEd25519KeyPair()?.secretKey + ) } + + // Refresh the migration state for 'SessionUtil' so it's logic can start running + // correctly when called (doing this here instead of automatically via the + // `SessionUtil.userConfigsEnabled` property to avoid having to use the correct + // method when calling within a database read/write closure) + Storage.shared.read { db in SessionUtil.refreshingUserConfigsEnabled(db) } + + migrationsCompletion(result, (needsConfigSync || SessionUtil.needsSync)) + + // The 'if' is only there to prevent the "variable never read" warning from showing + if backgroundTask != nil { backgroundTask = nil } } ) } diff --git a/SignalUtilitiesKit/Utilities/AppVersion.m b/SignalUtilitiesKit/Utilities/AppVersion.m index efea79e1d..993e99475 100755 --- a/SignalUtilitiesKit/Utilities/AppVersion.m +++ b/SignalUtilitiesKit/Utilities/AppVersion.m @@ -4,6 +4,7 @@ #import "AppVersion.h" #import "NSUserDefaults+OWS.h" +#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/Bench.swift b/SignalUtilitiesKit/Utilities/Bench.swift index 298ab6b01..cfcf5d7ad 100644 --- a/SignalUtilitiesKit/Utilities/Bench.swift +++ b/SignalUtilitiesKit/Utilities/Bench.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation +import SignalCoreKit /// Benchmark async code by calling the passed in block parameter when the work /// is done. diff --git a/SignalUtilitiesKit/Utilities/ByteParser.m b/SignalUtilitiesKit/Utilities/ByteParser.m index f8f1f6b10..4dd7c38db 100644 --- a/SignalUtilitiesKit/Utilities/ByteParser.m +++ b/SignalUtilitiesKit/Utilities/ByteParser.m @@ -3,6 +3,7 @@ // #import "ByteParser.h" +#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/DirectionalPanGestureRecognizer.swift b/SignalUtilitiesKit/Utilities/DirectionalPanGestureRecognizer.swift index 012457ce6..f73681411 100644 --- a/SignalUtilitiesKit/Utilities/DirectionalPanGestureRecognizer.swift +++ b/SignalUtilitiesKit/Utilities/DirectionalPanGestureRecognizer.swift @@ -76,11 +76,11 @@ public class DirectionalPanGestureRecognizer: UIPanGestureRecognizer { let vel = velocity(in: view) switch direction { case .left, .right: - if fabs(vel.y) > fabs(vel.x) { + if abs(vel.y) > abs(vel.x) { state = .cancelled } case .up, .down: - if fabs(vel.x) > fabs(vel.y) { + if abs(vel.x) > abs(vel.y) { state = .cancelled } default: diff --git a/SignalUtilitiesKit/Utilities/FunctionalUtil.m b/SignalUtilitiesKit/Utilities/FunctionalUtil.m index 830b47812..65fe6dc5c 100644 --- a/SignalUtilitiesKit/Utilities/FunctionalUtil.m +++ b/SignalUtilitiesKit/Utilities/FunctionalUtil.m @@ -3,6 +3,7 @@ // #import "FunctionalUtil.h" +#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/NSAttributedString+OWS.h b/SignalUtilitiesKit/Utilities/NSAttributedString+OWS.h deleted file mode 100644 index 601d4b35d..000000000 --- a/SignalUtilitiesKit/Utilities/NSAttributedString+OWS.h +++ /dev/null @@ -1,14 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@interface NSAttributedString (OWS) - -- (NSAttributedString *)rtlSafeAppend:(NSString *)text attributes:(NSDictionary *)attributes; -- (NSAttributedString *)rtlSafeAppend:(NSAttributedString *)string; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/NSAttributedString+OWS.m b/SignalUtilitiesKit/Utilities/NSAttributedString+OWS.m deleted file mode 100644 index 936a15b2f..000000000 --- a/SignalUtilitiesKit/Utilities/NSAttributedString+OWS.m +++ /dev/null @@ -1,39 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "NSAttributedString+OWS.h" -#import "UIView+OWS.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@implementation NSAttributedString (OWS) - -- (NSAttributedString *)rtlSafeAppend:(NSString *)text attributes:(NSDictionary *)attributes -{ - OWSAssertDebug(text); - OWSAssertDebug(attributes); - - NSAttributedString *substring = [[NSAttributedString alloc] initWithString:text attributes:attributes]; - return [self rtlSafeAppend:substring]; -} - -- (NSAttributedString *)rtlSafeAppend:(NSAttributedString *)string -{ - OWSAssertDebug(string); - - NSMutableAttributedString *result = [NSMutableAttributedString new]; - if (CurrentAppContext().isRTL) { - [result appendAttributedString:string]; - [result appendAttributedString:self]; - } else { - [result appendAttributedString:self]; - [result appendAttributedString:string]; - } - return [result copy]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift index f6a399a6f..a832873b2 100644 --- a/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift +++ b/SignalUtilitiesKit/Utilities/NoopNotificationsManager.swift @@ -3,19 +3,20 @@ import Foundation import GRDB import SessionMessagingKit +import SignalCoreKit public class NoopNotificationsManager: NotificationsProtocol { public init() {} - public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { owsFailDebug("") } - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { owsFailDebug("") } - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread) { + public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) { owsFailDebug("") } diff --git a/SignalUtilitiesKit/Utilities/OWSDispatch.h b/SignalUtilitiesKit/Utilities/OWSDispatch.h deleted file mode 100644 index ef0dccd93..000000000 --- a/SignalUtilitiesKit/Utilities/OWSDispatch.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSDispatch : NSObject - -/** - * Attachment downloading - */ -+ (dispatch_queue_t)attachmentsQueue; - -/** - * Serial message sending queue - */ -+ (dispatch_queue_t)sendingQueue; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSDispatch.m b/SignalUtilitiesKit/Utilities/OWSDispatch.m deleted file mode 100644 index 5f300a2f9..000000000 --- a/SignalUtilitiesKit/Utilities/OWSDispatch.m +++ /dev/null @@ -1,34 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -#import "OWSDispatch.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation OWSDispatch - -+ (dispatch_queue_t)attachmentsQueue -{ - static dispatch_once_t onceToken; - static dispatch_queue_t queue; - dispatch_once(&onceToken, ^{ - queue = dispatch_queue_create("org.whispersystems.signal.attachments", NULL); - }); - return queue; -} - -+ (dispatch_queue_t)sendingQueue -{ - static dispatch_once_t onceToken; - static dispatch_queue_t queue; - dispatch_once(&onceToken, ^{ - dispatch_queue_attr_t attributes = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, 0); - queue = dispatch_queue_create("org.whispersystems.signal.sendQueue", attributes); - }); - return queue; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSError.m b/SignalUtilitiesKit/Utilities/OWSError.m index 2577bb11a..f8096d70e 100644 --- a/SignalUtilitiesKit/Utilities/OWSError.m +++ b/SignalUtilitiesKit/Utilities/OWSError.m @@ -3,6 +3,7 @@ // #import "OWSError.h" +#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/OWSFormat.h b/SignalUtilitiesKit/Utilities/OWSFormat.h deleted file mode 100644 index aa0fb794d..000000000 --- a/SignalUtilitiesKit/Utilities/OWSFormat.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -NS_ASSUME_NONNULL_BEGIN - -@interface OWSFormat : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -+ (NSString *)formatInt:(int)value; - -+ (NSString *)formatFileSize:(unsigned long)fileSize; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSFormat.m b/SignalUtilitiesKit/Utilities/OWSFormat.m deleted file mode 100644 index f94660e52..000000000 --- a/SignalUtilitiesKit/Utilities/OWSFormat.m +++ /dev/null @@ -1,47 +0,0 @@ -// -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. -// - -#import "OWSFormat.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation OWSFormat - -+ (NSString *)formatInt:(int)value -{ - static NSNumberFormatter *formatter = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSNumberFormatter new]; - formatter.numberStyle = NSNumberFormatterNoStyle; - }); - return [formatter stringFromNumber:@(value)]; -} - -+ (NSString *)formatFileSize:(unsigned long)fileSize -{ - static NSNumberFormatter *formatter = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - formatter = [NSNumberFormatter new]; - formatter.numberStyle = NSNumberFormatterDecimalStyle; - }); - - const unsigned long kOneKilobyte = 1024; - const unsigned long kOneMegabyte = kOneKilobyte * kOneKilobyte; - - if (fileSize > kOneMegabyte) { - return [[formatter stringFromNumber:@((double)lround(fileSize * 100 / (CGFloat)kOneMegabyte) / 100)] - stringByAppendingString:@" MB"]; - } else if (fileSize > kOneKilobyte) { - return [[formatter stringFromNumber:@((double)lround(fileSize * 100 / (CGFloat)kOneKilobyte) / 100)] - stringByAppendingString:@" KB"]; - } else { - return [NSString stringWithFormat:@"%lu Bytes", fileSize]; - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/OWSOperation.m b/SignalUtilitiesKit/Utilities/OWSOperation.m index 5c496949a..47e511990 100644 --- a/SignalUtilitiesKit/Utilities/OWSOperation.m +++ b/SignalUtilitiesKit/Utilities/OWSOperation.m @@ -3,8 +3,11 @@ // #import "OWSOperation.h" -#import "OWSBackgroundTask.h" #import "OWSError.h" +#import +#import +#import +#import NS_ASSUME_NONNULL_BEGIN diff --git a/SignalUtilitiesKit/Utilities/OrderedDictionary.swift b/SignalUtilitiesKit/Utilities/OrderedDictionary.swift index 5e38f9e4d..549e05aec 100644 --- a/SignalUtilitiesKit/Utilities/OrderedDictionary.swift +++ b/SignalUtilitiesKit/Utilities/OrderedDictionary.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation +import SignalCoreKit public class OrderedDictionary { diff --git a/SignalUtilitiesKit/Utilities/OutageDetection.swift b/SignalUtilitiesKit/Utilities/OutageDetection.swift deleted file mode 100644 index 8f680505f..000000000 --- a/SignalUtilitiesKit/Utilities/OutageDetection.swift +++ /dev/null @@ -1,127 +0,0 @@ -// -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// - -import Foundation -import os - -@objc -public class OutageDetection: NSObject { - @objc(sharedManager) - public static let shared = OutageDetection() - - @objc public static let outageStateDidChange = Notification.Name("OutageStateDidChange") - - // These properties should only be accessed on the main thread. - @objc - public var hasOutage = false { - didSet { - AssertIsOnMainThread() - - if hasOutage != oldValue { - Logger.info("hasOutage: \(hasOutage).") - - NotificationCenter.default.postNotificationNameAsync(OutageDetection.outageStateDidChange, object: nil) - } - } - } - private var shouldCheckForOutage = false { - didSet { - AssertIsOnMainThread() - ensureCheckTimer() - } - } - - // We only show the outage warning when we're certain there's an outage. - // DNS lookup failures, etc. are not considered an outage. - private func checkForOutageSync() -> Bool { - let host = CFHostCreateWithName(nil, "uptime.signal.org" as CFString).takeRetainedValue() - CFHostStartInfoResolution(host, .addresses, nil) - var success: DarwinBoolean = false - guard let addresses = CFHostGetAddressing(host, &success)?.takeUnretainedValue() as NSArray? else { - Logger.error("CFHostGetAddressing failed: no addresses.") - return false - } - guard success.boolValue else { - Logger.error("CFHostGetAddressing failed.") - return false - } - var isOutageDetected = false - for case let address as NSData in addresses { - var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - if getnameinfo(address.bytes.assumingMemoryBound(to: sockaddr.self), socklen_t(address.length), - &hostname, socklen_t(hostname.count), nil, 0, NI_NUMERICHOST) == 0 { - let addressString = String(cString: hostname) - let kHealthyAddress = "127.0.0.1" - let kOutageAddress = "127.0.0.2" - if addressString == kHealthyAddress { - // Do nothing. - } else if addressString == kOutageAddress { - isOutageDetected = true - } else { - owsFailDebug("unexpected address: \(addressString)") - } - } - } - return isOutageDetected - } - - private func checkForOutageAsync() { - Logger.info("") - - DispatchQueue.global().async { - let isOutageDetected = self.checkForOutageSync() - DispatchQueue.main.async { - self.hasOutage = isOutageDetected - } - } - } - - private var checkTimer: Timer? - private func ensureCheckTimer() { - // Only monitor for outages in the main app. - guard CurrentAppContext().isMainApp else { - return - } - - if shouldCheckForOutage { - if checkTimer != nil { - // Already has timer. - return - } - - // The TTL of the DNS record is 60 seconds. - checkTimer = WeakTimer.scheduledTimer(timeInterval: 60, target: self, userInfo: nil, repeats: true) { [weak self] _ in - AssertIsOnMainThread() - - guard CurrentAppContext().isMainAppAndActive else { - return - } - - guard let strongSelf = self else { - return - } - - strongSelf.checkForOutageAsync() - } - } else { - checkTimer?.invalidate() - checkTimer = nil - } - } - - @objc - public func reportConnectionSuccess() { - DispatchMainThreadSafe { - self.shouldCheckForOutage = false - self.hasOutage = false - } - } - - @objc - public func reportConnectionFailure() { - DispatchMainThreadSafe { - self.shouldCheckForOutage = true - } - } -} diff --git a/SignalUtilitiesKit/Utilities/ReachabilityManager.swift b/SignalUtilitiesKit/Utilities/ReachabilityManager.swift index 90e7d2e34..c9b884db8 100644 --- a/SignalUtilitiesKit/Utilities/ReachabilityManager.swift +++ b/SignalUtilitiesKit/Utilities/ReachabilityManager.swift @@ -1,10 +1,11 @@ -// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// import Foundation import Reachability +import SignalCoreKit +/// **Warning:** The simulator doesn't detect reachability correctly so if you are seeing odd/incorrect reachability states double +/// check on an actual device before trying to replace this implementation @objc public class SSKReachabilityManagerImpl: NSObject, SSKReachabilityManager { diff --git a/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift b/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift index 1133f4384..4eb689a4b 100644 --- a/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift +++ b/SignalUtilitiesKit/Utilities/ReverseDispatchQueue.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// import Foundation +import SignalCoreKit // This is intended to be a drop-in replacement for DispatchQueue // that processes its queue in reverse order. diff --git a/SignalUtilitiesKit/Utilities/SwiftSingletons.swift b/SignalUtilitiesKit/Utilities/SwiftSingletons.swift index b9a5617a7..5af0a3177 100644 --- a/SignalUtilitiesKit/Utilities/SwiftSingletons.swift +++ b/SignalUtilitiesKit/Utilities/SwiftSingletons.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. -// import Foundation +import SignalCoreKit public class SwiftSingletons: NSObject { public static let shared = SwiftSingletons() diff --git a/SignalUtilitiesKit/Utilities/UIAlertController+OWS.swift b/SignalUtilitiesKit/Utilities/UIAlertController+OWS.swift index a8dec91da..7ef870767 100644 --- a/SignalUtilitiesKit/Utilities/UIAlertController+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIAlertController+OWS.swift @@ -1,8 +1,7 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation +import SignalCoreKit extension UIAlertController { @objc diff --git a/SignalUtilitiesKit/Utilities/UIFont+OWS.h b/SignalUtilitiesKit/Utilities/UIFont+OWS.h deleted file mode 100644 index 741ed989b..000000000 --- a/SignalUtilitiesKit/Utilities/UIFont+OWS.h +++ /dev/null @@ -1,63 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface UIFont (OWS) - -+ (UIFont *)ows_thinFontWithSize:(CGFloat)size; - -+ (UIFont *)ows_lightFontWithSize:(CGFloat)size; - -+ (UIFont *)ows_regularFontWithSize:(CGFloat)size; - -+ (UIFont *)ows_mediumFontWithSize:(CGFloat)size; - -+ (UIFont *)ows_boldFontWithSize:(CGFloat)size; - -+ (UIFont *)ows_monospacedDigitFontWithSize:(CGFloat)size; - -#pragma mark - Icon Fonts - -+ (UIFont *)ows_fontAwesomeFont:(CGFloat)size; -+ (UIFont *)ows_dripIconsFont:(CGFloat)size; -+ (UIFont *)ows_elegantIconsFont:(CGFloat)size; - -#pragma mark - Dynamic Type - -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeTitle1Font; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeTitle2Font; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeTitle3Font; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeHeadlineFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeBodyFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeSubheadlineFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeFootnoteFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeCaption1Font; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeCaption2Font; - -#pragma mark - Dynamic Type Clamped - -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeLargeTitle1ClampedFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeTitle1ClampedFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeTitle2ClampedFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeTitle3ClampedFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeHeadlineClampedFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeBodyClampedFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeSubheadlineClampedFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeFootnoteClampedFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeCaption1ClampedFont; -@property (class, readonly, nonatomic) UIFont *ows_dynamicTypeCaption2ClampedFont; - -#pragma mark - Styles - -- (UIFont *)ows_italic; -- (UIFont *)ows_bold; -- (UIFont *)ows_mediumWeight; -- (UIFont *)ows_monospaced; - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/UIFont+OWS.m b/SignalUtilitiesKit/Utilities/UIFont+OWS.m deleted file mode 100644 index 39617cd13..000000000 --- a/SignalUtilitiesKit/Utilities/UIFont+OWS.m +++ /dev/null @@ -1,233 +0,0 @@ -// -// Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// - -#import "UIFont+OWS.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation UIFont (OWS) - -+ (UIFont *)ows_thinFontWithSize:(CGFloat)size -{ - return [UIFont systemFontOfSize:size weight:UIFontWeightThin]; -} - -+ (UIFont *)ows_lightFontWithSize:(CGFloat)size -{ - return [UIFont systemFontOfSize:size weight:UIFontWeightLight]; -} - -+ (UIFont *)ows_regularFontWithSize:(CGFloat)size -{ - return [UIFont systemFontOfSize:size weight:UIFontWeightRegular]; -} - -+ (UIFont *)ows_mediumFontWithSize:(CGFloat)size -{ - return [UIFont systemFontOfSize:size weight:UIFontWeightMedium]; -} - -+ (UIFont *)ows_boldFontWithSize:(CGFloat)size -{ - return [UIFont boldSystemFontOfSize:size]; -} - -+ (UIFont *)ows_monospacedDigitFontWithSize:(CGFloat)size; -{ - return [self monospacedDigitSystemFontOfSize:size weight:UIFontWeightRegular]; -} - -#pragma mark - Icon Fonts - -+ (UIFont *)ows_fontAwesomeFont:(CGFloat)size -{ - return [UIFont fontWithName:@"FontAwesome" size:size]; -} - -+ (UIFont *)ows_dripIconsFont:(CGFloat)size -{ - return [UIFont fontWithName:@"dripicons-v2" size:size]; -} - -+ (UIFont *)ows_elegantIconsFont:(CGFloat)size -{ - return [UIFont fontWithName:@"ElegantIcons" size:size]; -} - -#pragma mark - Dynamic Type - -+ (UIFont *)ows_dynamicTypeTitle1Font -{ - return [UIFont preferredFontForTextStyle:UIFontTextStyleTitle1]; -} - -+ (UIFont *)ows_dynamicTypeTitle2Font -{ - return [UIFont preferredFontForTextStyle:UIFontTextStyleTitle2]; -} - -+ (UIFont *)ows_dynamicTypeTitle3Font -{ - return [UIFont preferredFontForTextStyle:UIFontTextStyleTitle3]; -} - -+ (UIFont *)ows_dynamicTypeHeadlineFont -{ - return [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; -} - -+ (UIFont *)ows_dynamicTypeBodyFont -{ - return [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; -} - -+ (UIFont *)ows_dynamicTypeSubheadlineFont -{ - return [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline]; -} - -+ (UIFont *)ows_dynamicTypeFootnoteFont -{ - return [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]; -} - -+ (UIFont *)ows_dynamicTypeCaption1Font -{ - return [UIFont preferredFontForTextStyle:UIFontTextStyleCaption1]; -} - -+ (UIFont *)ows_dynamicTypeCaption2Font -{ - return [UIFont preferredFontForTextStyle:UIFontTextStyleCaption2]; -} - -#pragma mark - Dynamic Type Clamped - -+ (UIFont *)preferredFontForTextStyleClamped:(UIFontTextStyle)fontTextStyle -{ - // We clamp the dynamic type sizes at the max size available - // without "larger accessibility sizes" enabled. - static NSDictionary *maxPointSizeMap = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSMutableDictionary *map = [@{ - UIFontTextStyleTitle1 : @(34.0), - UIFontTextStyleTitle2 : @(28.0), - UIFontTextStyleTitle3 : @(26.0), - UIFontTextStyleHeadline : @(23.0), - UIFontTextStyleBody : @(23.0), - UIFontTextStyleSubheadline : @(21.0), - UIFontTextStyleFootnote : @(19.0), - UIFontTextStyleCaption1 : @(18.0), - UIFontTextStyleCaption2 : @(17.0), - } mutableCopy]; - map[UIFontTextStyleLargeTitle] = @(40.0); - maxPointSizeMap = map; - }); - - UIFont *font = [UIFont preferredFontForTextStyle:fontTextStyle]; - NSNumber *_Nullable maxPointSize = maxPointSizeMap[fontTextStyle]; - if (maxPointSize) { - if (maxPointSize.floatValue < font.pointSize) { - return [font fontWithSize:maxPointSize.floatValue]; - } - } else { - OWSFailDebug(@"Missing max point size for style: %@", fontTextStyle); - } - - return font; -} - -+ (UIFont *)ows_dynamicTypeLargeTitle1ClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleLargeTitle]; -} - -+ (UIFont *)ows_dynamicTypeTitle1ClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleTitle1]; -} - -+ (UIFont *)ows_dynamicTypeTitle2ClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleTitle2]; -} - -+ (UIFont *)ows_dynamicTypeTitle3ClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleTitle3]; -} - -+ (UIFont *)ows_dynamicTypeHeadlineClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleHeadline]; -} - -+ (UIFont *)ows_dynamicTypeBodyClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleBody]; -} - -+ (UIFont *)ows_dynamicTypeSubheadlineClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleSubheadline]; -} - -+ (UIFont *)ows_dynamicTypeFootnoteClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleFootnote]; -} - -+ (UIFont *)ows_dynamicTypeCaption1ClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleCaption1]; -} - -+ (UIFont *)ows_dynamicTypeCaption2ClampedFont -{ - return [UIFont preferredFontForTextStyleClamped:UIFontTextStyleCaption2]; -} - -#pragma mark - Styles - -- (UIFont *)ows_italic -{ - return [self styleWithSymbolicTraits:UIFontDescriptorTraitItalic]; -} - -- (UIFont *)ows_bold -{ - return [self styleWithSymbolicTraits:UIFontDescriptorTraitBold]; -} - -- (UIFont *)styleWithSymbolicTraits:(UIFontDescriptorSymbolicTraits)symbolicTraits -{ - UIFontDescriptor *fontDescriptor = [self.fontDescriptor fontDescriptorWithSymbolicTraits:symbolicTraits]; - UIFont *font = [UIFont fontWithDescriptor:fontDescriptor size:0]; - OWSAssertDebug(font); - return font ?: self; -} - -- (UIFont *)ows_mediumWeight -{ - // The recommended approach of deriving "medium" weight fonts for dynamic - // type fonts is: - // - // [UIFontDescriptor fontDescriptorByAddingAttributes:...] - // - // But this doesn't seem to work in practice on iOS 11 using UIFontWeightMedium. - - UIFont *derivedFont = [UIFont systemFontOfSize:self.pointSize weight:UIFontWeightMedium]; - return derivedFont; -} - -- (UIFont *)ows_monospaced -{ - return [self.class ows_monospacedDigitFontWithSize:self.pointSize]; -} - - -@end - -NS_ASSUME_NONNULL_END diff --git a/SignalUtilitiesKit/Utilities/UIView+OWS.swift b/SignalUtilitiesKit/Utilities/UIView+OWS.swift index b3a9978e4..de1a7c5cd 100644 --- a/SignalUtilitiesKit/Utilities/UIView+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIView+OWS.swift @@ -1,9 +1,8 @@ -// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. -// import Foundation import SessionUIKit +import SignalCoreKit public extension UIEdgeInsets { init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { diff --git a/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift b/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift index c9762da43..6791a15e1 100644 --- a/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift +++ b/SignalUtilitiesKit/Utilities/UIViewController+OWS.swift @@ -14,6 +14,16 @@ public extension UIViewController { var nextViewController: UIViewController? = viewController.presentedViewController + if + let topBannerController: TopBannerController = nextViewController as? TopBannerController, + !topBannerController.children.isEmpty + { + nextViewController = ( + topBannerController.children[0].presentedViewController ?? + topBannerController.children[0] + ) + } + if let nextViewController: UIViewController = nextViewController { if !ignoringAlerts || !(nextViewController is UIAlertController) { if visitedViewControllers.contains(nextViewController) { diff --git a/_SharedTestUtilities/CombineExtensions.swift b/_SharedTestUtilities/CombineExtensions.swift index 4a914cff8..057a2ddb3 100644 --- a/_SharedTestUtilities/CombineExtensions.swift +++ b/_SharedTestUtilities/CombineExtensions.swift @@ -2,13 +2,27 @@ import Foundation import Combine +import SessionUtilitiesKit + +public extension Publisher { + func sinkAndStore(in storage: inout C) where C: RangeReplaceableCollection, C.Element == AnyCancellable { + self + .subscribe(on: ImmediateScheduler.shared) + .receive(on: ImmediateScheduler.shared) + .sink( + receiveCompletion: { _ in }, + receiveValue: { _ in } + ) + .store(in: &storage) + } +} public extension AnyPublisher { func firstValue() -> Output? { var value: Output? _ = self - .receiveOnMain(immediately: true) + .receive(on: ImmediateScheduler.shared) .sink( receiveCompletion: { _ in }, receiveValue: { result in value = result } diff --git a/_SharedTestUtilities/CommonMockedExtensions.swift b/_SharedTestUtilities/CommonMockedExtensions.swift index b70e06327..06876f038 100644 --- a/_SharedTestUtilities/CommonMockedExtensions.swift +++ b/_SharedTestUtilities/CommonMockedExtensions.swift @@ -6,8 +6,8 @@ import Sodium import Curve25519Kit import SessionUtilitiesKit -extension Box.KeyPair: Mocked { - static var mockValue: Box.KeyPair = Box.KeyPair( +extension KeyPair: Mocked { + static var mockValue: KeyPair = KeyPair( publicKey: Data(hex: TestConstants.publicKey).bytes, secretKey: Data(hex: TestConstants.edSecretKey).bytes ) diff --git a/_SharedTestUtilities/MockGeneralCache.swift b/_SharedTestUtilities/MockGeneralCache.swift index 0d3c55b78..fe19b7a0f 100644 --- a/_SharedTestUtilities/MockGeneralCache.swift +++ b/_SharedTestUtilities/MockGeneralCache.swift @@ -3,9 +3,7 @@ import Foundation import SessionUtilitiesKit -@testable import SessionMessagingKit - -class MockGeneralCache: Mock, GeneralCacheType { +class MockGeneralCache: Mock, MutableGeneralCacheType { var encodedPublicKey: String? { get { return accept() as? String } set { accept(args: [newValue]) } diff --git a/_SharedTestUtilities/MockJobRunner.swift b/_SharedTestUtilities/MockJobRunner.swift index 494643ed2..6f34caff6 100644 --- a/_SharedTestUtilities/MockJobRunner.swift +++ b/_SharedTestUtilities/MockJobRunner.swift @@ -13,22 +13,14 @@ class MockJobRunner: Mock, JobRunnerType { accept(args: [executor, variant]) } - func canStart(queue: JobQueue) -> Bool { + func canStart(queue: JobQueue?) -> Bool { return accept(args: [queue]) as! Bool } // MARK: - State Management - func isCurrentlyRunning(_ job: Job?) -> Bool { - return accept(args: [job]) as! Bool - } - - func hasJob(of variant: Job.Variant, inState state: JobRunner.JobState, with jobDetails: T) -> Bool { - return accept(args: [variant, state, jobDetails]) as! Bool - } - - func detailsFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: Data?] { - return accept(args: [jobs, state, variant]) as! [Int64: Data?] + func jobInfoFor(jobs: [Job]?, state: JobRunner.JobState, variant: Job.Variant?) -> [Int64: JobRunner.JobInfo] { + return accept(args: [jobs, state, variant]) as! [Int64: JobRunner.JobInfo] } func appDidFinishLaunching(dependencies: Dependencies) {} @@ -42,8 +34,8 @@ class MockJobRunner: Mock, JobRunnerType { // MARK: - Job Scheduling - func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) { - accept(args: [db, job, canStartJob]) + @discardableResult func add(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) -> Job? { + return accept(args: [db, job, canStartJob]) as? Job } func upsert(_ db: Database, job: Job?, canStartJob: Bool, dependencies: Dependencies) { diff --git a/_SharedTestUtilities/SynchronousStorage.swift b/_SharedTestUtilities/SynchronousStorage.swift index fdda5a983..0e9e87655 100644 --- a/_SharedTestUtilities/SynchronousStorage.swift +++ b/_SharedTestUtilities/SynchronousStorage.swift @@ -1,28 +1,36 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. -import Foundation +import Combine import GRDB -import PromiseKit import SessionUtilitiesKit class SynchronousStorage: Storage { - override func writeAsync(updates: @escaping (Database) throws -> T) { - super.write(updates: updates) + override func readPublisher( + value: @escaping (Database) throws -> T + ) -> AnyPublisher { + guard let result: T = super.read(value) else { + return Fail(error: StorageError.generic) + .eraseToAnyPublisher() + } + + return Just(result) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } - override func writeAsync(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result) throws -> Void) { - super.write { db in - do { - var result: T? - try db.inTransaction { - result = try updates(db) - return .commit - } - try? completion(db, .success(result!)) - } - catch { - try? completion(db, .failure(error)) - } + override func writePublisher( + fileName: String = #file, + functionName: String = #function, + lineNumber: Int = #line, + updates: @escaping (Database) throws -> T + ) -> AnyPublisher { + guard let result: T = super.write(fileName: fileName, functionName: functionName, lineNumber: lineNumber, updates: updates) else { + return Fail(error: StorageError.generic) + .eraseToAnyPublisher() } + + return Just(result) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() } }