diff --git a/Podfile b/Podfile index 5747d4620..25f76e8e9 100644 --- a/Podfile +++ b/Podfile @@ -101,6 +101,7 @@ end # Actions to perform post-install post_install do |installer| set_minimum_deployment_target(installer) + avoid_rsync_webrtc_if_unchanged(installer) end def set_minimum_deployment_target(installer) @@ -110,3 +111,12 @@ def set_minimum_deployment_target(installer) end end end + +# This function patches the Cocoapods 'Embed Frameworks' script to avoid running rsync +# for the WebRTC-lib framework in simulator builds if it has already been copied over +# because due to the size it can take over 10 seconds to embed, and gets embeded in +# each target on every build regardless of whether there were changes, drastically +# increasing the length of the build +def avoid_rsync_webrtc_if_unchanged(installer) + system('find "./Pods/Target Support Files" -name "*-frameworks.sh" -exec patch -p0 -i ./Scripts/skip_web_rtc_re_rsync.patch {} \;') +end diff --git a/Podfile.lock b/Podfile.lock index 970a62e71..d44b761b9 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -204,6 +204,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 68799237a4dc046f5ac25c573af03b559f5b10c4 +PODFILE CHECKSUM: dcca0c4ad69b14cbc2d6ba49f9d690b239828e6d COCOAPODS: 1.12.1 diff --git a/Scripts/skip_web_rtc_re_rsync.patch b/Scripts/skip_web_rtc_re_rsync.patch new file mode 100644 index 000000000..6b8232e50 --- /dev/null +++ b/Scripts/skip_web_rtc_re_rsync.patch @@ -0,0 +1,12 @@ +@@ -41,0 +41,11 @@ ++ # Skip the rsync step for the WebRTC-lib in simulator builds ++ if [[ "$PLATFORM_NAME" == "iphonesimulator" ]] && [[ "$source" == *WebRTC.framework* ]]; then ++ if [[ -f "${source}/../already_rsynced.nonce" ]]; then ++ echo "Already rsynced WebRTC, skipping" ++ return 0 ++ fi ++ ++ echo "About to rsync a simulator WebRTC, creating nonce to prevent future rsyncing" ++ touch "${source}/../already_rsynced.nonce" ++ fi ++ diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index eff615d2f..45cbabab4 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -703,6 +703,7 @@ FD716E6C28505E1C00C96BF4 /* MessageRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */; }; FD716E7128505E5200C96BF4 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */; }; FD716E722850647600C96BF4 /* Data+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EF127BF6BA200510D0C /* Data+Utilities.swift */; }; + FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */; }; FD7728962849E7E90018502F /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728952849E7E90018502F /* String+Utilities.swift */; }; FD7728982849E8110018502F /* UITableView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7728972849E8110018502F /* UITableView+ReusableView.swift */; }; FD77289A284AF1BD0018502F /* Sodium+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD772899284AF1BD0018502F /* Sodium+Utilities.swift */; }; @@ -1846,6 +1847,7 @@ FD716E692850327900C96BF4 /* EndCallMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndCallMode.swift; sourceTree = ""; }; FD716E6B28505E1C00C96BF4 /* MessageRequestsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewModel.swift; sourceTree = ""; }; FD716E7028505E5100C96BF4 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = ""; }; + FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionThreadViewModelSpec.swift; sourceTree = ""; }; FD7728952849E7E90018502F /* String+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Utilities.swift"; sourceTree = ""; }; FD7728972849E8110018502F /* UITableView+ReusableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+ReusableView.swift"; sourceTree = ""; }; FD772899284AF1BD0018502F /* Sodium+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Sodium+Utilities.swift"; sourceTree = ""; }; @@ -3346,10 +3348,10 @@ B8DE1FB226C22F1F0079C9CE /* Calls */, C32C5BCB256DC818003C73A2 /* Database */, C300A5BB2554AFFB00555489 /* Messages */, - C300A5F02554B08500555489 /* Sending & Receiving */, C352A2F325574B3300338F3E /* Jobs */, C3A7215C2558C0AC0043A11F /* File Server */, C3A721332558BDDF0043A11F /* Open Groups */, + C300A5F02554B08500555489 /* Sending & Receiving */, FD8ECF7529340F4800C0D1BB /* SessionUtil */, FD3E0C82283B581F002A425C /* Shared Models */, C3BBE0B32554F0D30050F1E3 /* Utilities */, @@ -4027,6 +4029,14 @@ path = Views; sourceTree = ""; }; + FD7692F52A53A2C7000E4B70 /* Shared Models */ = { + isa = PBXGroup; + children = ( + FD7692F62A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift */, + ); + path = "Shared Models"; + sourceTree = ""; + }; FD7728A1284F0DF50018502F /* Message Handling */ = { isa = PBXGroup; children = ( @@ -4258,6 +4268,7 @@ FD3C906527E416A200CD579F /* Contacts */, FDC4389827BA001800C60D73 /* Open Groups */, FD3C906B27E43C2400CD579F /* Sending & Receiving */, + FD7692F52A53A2C7000E4B70 /* Shared Models */, FD3C906827E417B100CD579F /* Utilities */, FD8ECF802934385900C0D1BB /* LibSessionUtil */, ); @@ -6441,6 +6452,7 @@ FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */, FD8ECF822934387A00C0D1BB /* ConfigUserProfileSpec.swift in Sources */, FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, + FD7692F72A53A2ED000E4B70 /* SessionThreadViewModelSpec.swift in Sources */, FDC2908F27D70938005DAE71 /* SendDirectMessageRequestSpec.swift in Sources */, FDC438BD27BB2AB400C60D73 /* Mockable.swift in Sources */, FDA1E83929A5771A00C5C3BD /* LibSessionSpec.swift in Sources */, @@ -6637,7 +6649,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 414; + CURRENT_PROJECT_VERSION = 415; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6709,7 +6721,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 414; + CURRENT_PROJECT_VERSION = 415; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6774,7 +6786,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 414; + CURRENT_PROJECT_VERSION = 415; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6848,7 +6860,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 414; + CURRENT_PROJECT_VERSION = 415; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -7756,7 +7768,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 414; + CURRENT_PROJECT_VERSION = 415; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7827,7 +7839,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 414; + CURRENT_PROJECT_VERSION = 415; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 4f38033ad..7ec9e1cb8 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -2010,8 +2010,7 @@ extension ConversationVC: self?.showInputAccessoryView() }) - self.inputAccessoryView?.isHidden = true - self.inputAccessoryView?.alpha = 0 + self.hideInputAccessoryView() Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view) self.present(actionSheet, animated: true) } diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 42cb93344..6b91630da 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -106,6 +106,7 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController 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 }() diff --git a/Session/Notifications/SyncPushTokensJob.swift b/Session/Notifications/SyncPushTokensJob.swift index 2a93618fb..2df9657b3 100644 --- a/Session/Notifications/SyncPushTokensJob.swift +++ b/Session/Notifications/SyncPushTokensJob.swift @@ -42,75 +42,97 @@ public enum SyncPushTokensJob: JobExecutor { } }() - // 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 || - !isRegisteredForRemoteNotifications || - Date().timeIntervalSince(lastPushNotificationSync) >= SyncPushTokensJob.maxFrequency - else { - SNLog("[SyncPushTokensJob] Deferred due to Fast Mode disabled or recent-enough registration") + // 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) // 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] - // Perform device registration - PushRegistrationManager.shared.requestPushTokens() - .subscribe(on: queue) - .flatMap { (pushToken: String, voipToken: String) -> AnyPublisher in - Deferred { - Future { resolver in - SyncPushTokensJob.registerForPushNotifications( - pushToken: pushToken, - voipToken: voipToken, - isForcedUpdate: true, - success: { resolver(Result.success(())) }, - failure: { resolver(Result.failure($0)) } - ) + // 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() } - .handleEvents( + .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 .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 - } + 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) } ) - .eraseToAnyPublisher() + return + } + + // Perform device registration + Logger.info("Re-registering for remote notifications.") + PushRegistrationManager.shared.requestPushTokens() + .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 + } + } + } + ) + .map { _ in () } + .eraseToAnyPublisher() } + .subscribe(on: queue) .sinkUntilComplete( // We want to complete this job regardless of success or failure - receiveCompletion: { _ in success(job, false) }, - receiveValue: { _ in } + receiveCompletion: { _ in success(job, false) } ) } @@ -147,68 +169,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] - - Just(Data(hex: pushToken)) - .setFailureType(to: Error.self) - .flatMap { pushTokenAsData -> AnyPublisher in - guard isUsingFullAPNs else { - return PushNotificationAPI.unregister(pushTokenAsData) - .map { _ in true } - .eraseToAnyPublisher() - } - - return PushNotificationAPI - .register( - with: pushTokenAsData, - publicKey: getUserHexEncodedPublicKey(), - isForcedUpdate: isForcedUpdate - ) - .map { _ in true } - .eraseToAnyPublisher() - } - .catch { error -> AnyPublisher in - guard remainingRetries == 0 else { - SyncPushTokensJob.registerForPushNotifications( - pushToken: pushToken, - voipToken: voipToken, - isForcedUpdate: isForcedUpdate, - success: success, - failure: failure, - remainingRetries: (remainingRetries - 1) - ) - - return Just(false) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - return Fail(error: error) - .eraseToAnyPublisher() - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete( - receiveCompletion: { result in - switch result { - case .finished: break - case .failure(let error): failure(error) - } - }, - receiveValue: { didComplete in - guard didComplete else { return } - - success() - } - ) - } -} diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 2fcdfa075..eb1ee1164 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -1131,19 +1131,22 @@ public extension SessionThreadViewModel { /// 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 (as long as the last word doesn't end in a quote) - return standardQuotes(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 } + 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 { @@ -1174,15 +1177,17 @@ public extension SessionThreadViewModel { /// 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> { diff --git a/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift b/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift new file mode 100644 index 000000000..df58e26c3 --- /dev/null +++ b/SessionMessagingKitTests/Shared Models/SessionThreadViewModelSpec.swift @@ -0,0 +1,334 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import Quick +import Nimble +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class SessionThreadViewModelSpec: QuickSpec { + public struct TestMessage: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "testMessage" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case body + } + + public let body: String + } + + // MARK: - Spec + + override func spec() { + describe("a SessionThreadViewModel") { + var mockStorage: Storage! + + beforeEach { + mockStorage = SynchronousStorage( + customWriter: try! DatabaseQueue() + ) + + mockStorage.write { db in + try db.create(table: TestMessage.self) { t in + t.column(.body, .text).notNull() + } + + try db.create(virtualTable: TestMessage.fullTextSearchTableName, using: FTS5()) { t in + t.synchronize(withTable: TestMessage.databaseTableName) + t.tokenizer = .porter(wrapping: .unicode61()) + + t.column(TestMessage.Columns.body.name) + } + } + } + + // MARK: - when processing a search term + context("when processing a search term") { + // MARK: -- correctly generates a safe search term + it("correctly generates a safe search term") { + expect(SessionThreadViewModel.searchSafeTerm("Test")).to(equal("\"Test\"")) + } + + // MARK: -- standardises odd quote characters + it("standardises odd quote characters") { + expect(SessionThreadViewModel.standardQuotes("\"")).to(equal("\"")) + expect(SessionThreadViewModel.standardQuotes("”")).to(equal("\"")) + expect(SessionThreadViewModel.standardQuotes("“")).to(equal("\"")) + } + + // MARK: -- splits on the space character + it("splits on the space character") { + expect(SessionThreadViewModel.searchTermParts("Test Message")) + .to(equal([ + "\"Test\"", + "\"Message\"" + ])) + } + + // MARK: -- surrounds each split term with quotes + it("surrounds each split term with quotes") { + expect(SessionThreadViewModel.searchTermParts("Test Message")) + .to(equal([ + "\"Test\"", + "\"Message\"" + ])) + } + + // MARK: -- keeps words within quotes together + it("keeps words within quotes together") { + expect(SessionThreadViewModel.searchTermParts("This \"is a Test\" Message")) + .to(equal([ + "\"This\"", + "\"is a Test\"", + "\"Message\"" + ])) + expect(SessionThreadViewModel.searchTermParts("\"This is\" a Test Message")) + .to(equal([ + "\"This is\"", + "\"a\"", + "\"Test\"", + "\"Message\"" + ])) + expect(SessionThreadViewModel.searchTermParts("\"This is\" \"a Test\" Message")) + .to(equal([ + "\"This is\"", + "\"a Test\"", + "\"Message\"" + ])) + expect(SessionThreadViewModel.searchTermParts("\"This is\" a \"Test Message\"")) + .to(equal([ + "\"This is\"", + "\"a\"", + "\"Test Message\"" + ])) + expect(SessionThreadViewModel.searchTermParts("\"This is\"\" a \"Test Message")) + .to(equal([ + "\"This is\"", + "\" a \"", + "\"Test\"", + "\"Message\"" + ])) + } + + // MARK: -- keeps words within weird quotes together + it("keeps words within weird quotes together") { + expect(SessionThreadViewModel.searchTermParts("This ”is a Test“ Message")) + .to(equal([ + "\"This\"", + "\"is a Test\"", + "\"Message\"" + ])) + } + + // MARK: -- removes extra whitespace + it("removes extra whitespace") { + expect(SessionThreadViewModel.searchTermParts(" Test Message ")) + .to(equal([ + "\"Test\"", + "\"Message\"" + ])) + } + } + + // MARK: - when searching + context("when searching") { + beforeEach { + mockStorage.write { db in + try TestMessage(body: "Test").insert(db) + try TestMessage(body: "Test123").insert(db) + try TestMessage(body: "Test234").insert(db) + try TestMessage(body: "Test Test123").insert(db) + try TestMessage(body: "Test Test123 Test234").insert(db) + try TestMessage(body: "Test Test234").insert(db) + try TestMessage(body: "Test Test234 Test123").insert(db) + try TestMessage(body: "This is a Test Message").insert(db) + try TestMessage(body: "is a Message This Test").insert(db) + try TestMessage(body: "this message is a test").insert(db) + try TestMessage( + body: "This content is something which includes a combination of test words found in another message" + ) + .insert(db) + try TestMessage(body: "Do test messages contain content?").insert(db) + try TestMessage(body: "Is messaging awesome?").insert(db) + } + } + + // MARK: -- returns results + it("returns results") { + let results = mockStorage.read { db in + let pattern: FTS5Pattern = try SessionThreadViewModel.pattern( + db, + searchTerm: "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: -- 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/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 0526360a4..2db3e27ba 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -237,7 +237,8 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension }, migrationsCompletion: { [weak self] result, needsConfigSync in switch result { - case .failure: SNLog("[NotificationServiceExtension] Failed to complete migrations") + // 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) diff --git a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift index e05ff4261..c381bf14a 100644 --- a/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadDisappearingMessagesViewModelSpec.swift @@ -4,6 +4,8 @@ import Combine import GRDB import Quick import Nimble +import SessionUIKit +import SessionSnodeKit @testable import Session diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 2750a3803..eaf4b915b 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