diff --git a/Podfile b/Podfile index a03402eba..c7a904233 100644 --- a/Podfile +++ b/Podfile @@ -90,6 +90,7 @@ def disable_optimizations_for_tests(installer) # Allow accurate step-thru debugging while in tests build_config.build_settings['GCC_OPTIMIZATION_LEVEL'] = '0' + build_config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-Onone' end end diff --git a/Podfile.lock b/Podfile.lock index 7e4dd9918..1bf3842c6 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -237,6 +237,6 @@ SPEC CHECKSUMS: YapDatabase: 299a32de9d350d37a9ac5b0532609d87d5d2a5de YYImage: 1e1b62a9997399593e4b9c4ecfbbabbf1d3f3b54 -PODFILE CHECKSUM: 6a1bafb7c5bedfa4e577580ff12e487cc7111f38 +PODFILE CHECKSUM: 497de356704a8d141a2dd132ab5c7fa4acffe2b6 COCOAPODS: 1.5.3 diff --git a/Pods b/Pods index 47c8a2611..8c7fd79eb 160000 --- a/Pods +++ b/Pods @@ -1 +1 @@ -Subproject commit 47c8a2611481201e17387b4335421f373f8c8e9b +Subproject commit 8c7fd79eba19166ed7b3d4b09b11e11ab130e457 diff --git a/Signal/test/util/SearcherTest.swift b/Signal/test/util/SearcherTest.swift index 7983082b9..0fcba4d57 100644 --- a/Signal/test/util/SearcherTest.swift +++ b/Signal/test/util/SearcherTest.swift @@ -7,7 +7,7 @@ import XCTest @testable import SignalMessaging @objc -class FakeEnvironment: TextSecureKitEnv { +class StubbableEnvironment: TextSecureKitEnv { let proxy: TextSecureKitEnv init(proxy: TextSecureKitEnv) { @@ -17,51 +17,36 @@ class FakeEnvironment: TextSecureKitEnv { var stubbedCallMessageHandler: OWSCallMessageHandler? override var callMessageHandler: OWSCallMessageHandler { - if let callMessageHandler = stubbedCallMessageHandler { - return callMessageHandler - } - return proxy.callMessageHandler + return stubbedCallMessageHandler ?? proxy.callMessageHandler } var stubbedContactsManager: ContactsManagerProtocol? override var contactsManager: ContactsManagerProtocol { - if let contactsManager = stubbedContactsManager { - return contactsManager - } - return proxy.contactsManager + return stubbedContactsManager ?? proxy.contactsManager } var stubbedMessageSender: MessageSender? override var messageSender: MessageSender { - if let messageSender = stubbedMessageSender { - return messageSender - } - return proxy.messageSender + return stubbedMessageSender ?? proxy.messageSender } var stubbedNotificationsManager: NotificationsProtocol? override var notificationsManager: NotificationsProtocol { - if let notificationsManager = stubbedNotificationsManager { - return notificationsManager - } - return proxy.notificationsManager + return stubbedNotificationsManager ?? proxy.notificationsManager } var stubbedProfileManager: ProfileManagerProtocol? override var profileManager: ProfileManagerProtocol { - if let profileManager = stubbedProfileManager { - return profileManager - } - return proxy.profileManager + return stubbedProfileManager ?? proxy.profileManager } } @objc class FakeContactsManager: NSObject, ContactsManagerProtocol { func displayName(forPhoneIdentifier phoneNumber: String?) -> String { - if phoneNumber == "+12345678900" { + if phoneNumber == aliceRecipientId { return "Alice" - } else if phoneNumber == "+49030183000" { + } else if phoneNumber == bobRecipientId { return "Bob Barker" } else { return "" @@ -81,6 +66,9 @@ class FakeContactsManager: NSObject, ContactsManagerProtocol { } } +let bobRecipientId = "+49030183000" +let aliceRecipientId = "+12345678900" + class ConversationSearcherTest: XCTestCase { // MARK: - Dependencies @@ -113,24 +101,24 @@ class ConversationSearcherTest: XCTestCase { originalEnvironment = TextSecureKitEnv.shared() - let testEnvironment: FakeEnvironment = FakeEnvironment(proxy: originalEnvironment!) + let testEnvironment: StubbableEnvironment = StubbableEnvironment(proxy: originalEnvironment!) testEnvironment.stubbedContactsManager = FakeContactsManager() TextSecureKitEnv.setShared(testEnvironment) self.dbConnection.readWrite { transaction in - let bookModel = TSGroupModel(title: "Book Club", memberIds: ["+12345678900", "+49030183000"], image: nil, groupId: Randomness.generateRandomBytes(16)) + let bookModel = TSGroupModel(title: "Book Club", memberIds: [aliceRecipientId, bobRecipientId], image: nil, groupId: Randomness.generateRandomBytes(16)) let bookClubGroupThread = TSGroupThread.getOrCreateThread(with: bookModel, transaction: transaction) self.bookClubThread = ThreadViewModel(thread: bookClubGroupThread, transaction: transaction) - let snackModel = TSGroupModel(title: "Snack Club", memberIds: ["+12345678900"], image: nil, groupId: Randomness.generateRandomBytes(16)) + let snackModel = TSGroupModel(title: "Snack Club", memberIds: [aliceRecipientId], image: nil, groupId: Randomness.generateRandomBytes(16)) let snackClubGroupThread = TSGroupThread.getOrCreateThread(with: snackModel, transaction: transaction) self.snackClubThread = ThreadViewModel(thread: snackClubGroupThread, transaction: transaction) - let aliceContactThread = TSContactThread.getOrCreateThread(withContactId: "+12345678900", transaction: transaction) + let aliceContactThread = TSContactThread.getOrCreateThread(withContactId: aliceRecipientId, transaction: transaction) self.aliceThread = ThreadViewModel(thread: aliceContactThread, transaction: transaction) - let bobContactThread = TSContactThread.getOrCreateThread(withContactId: "+49030183000", transaction: transaction) - self.bobThread = ThreadViewModel(thread: bobContactThread, transaction: transaction) + let bobContactThread = TSContactThread.getOrCreateThread(withContactId: bobRecipientId, transaction: transaction) + self.bobEmptyThread = ThreadViewModel(thread: bobContactThread, transaction: transaction) let helloAlice = TSOutgoingMessage(in: aliceContactThread, messageBody: "Hello Alice", attachmentId: nil) helloAlice.save(with: transaction) @@ -152,7 +140,7 @@ class ConversationSearcherTest: XCTestCase { var snackClubThread: ThreadViewModel! var aliceThread: ThreadViewModel! - var bobThread: ThreadViewModel! + var bobEmptyThread: ThreadViewModel! // MARK: Tests @@ -191,7 +179,7 @@ class ConversationSearcherTest: XCTestCase { XCTAssertEqual(0, threads.count) // Exact match - threads = searchConversations(searchText: "+12345678900") + threads = searchConversations(searchText: aliceRecipientId) XCTAssertEqual(3, threads.count) XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) @@ -206,8 +194,8 @@ class ConversationSearcherTest: XCTestCase { XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) threads = searchConversations(searchText: "49") - XCTAssertEqual(2, threads.count) - XCTAssertEqual([bookClubThread, bobThread], threads) + XCTAssertEqual(1, threads.count) + XCTAssertEqual([bookClubThread], threads) threads = searchConversations(searchText: "1-234-56") XCTAssertEqual(3, threads.count) @@ -222,18 +210,16 @@ class ConversationSearcherTest: XCTestCase { XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) } - // TODO - func pending_testSearchContactByNumber() { - var resultSet: SearchResultSet = .empty - + func testSearchContactByNumberWithoutCountryCode() { + var threads: [ThreadViewModel] = [] // Phone Number formatting should be forgiving - resultSet = getResultSet(searchText: "234.56") - XCTAssertEqual(1, resultSet.conversations.count) - XCTAssertEqual(aliceThread, resultSet.conversations.first?.thread) + threads = searchConversations(searchText: "234.56") + XCTAssertEqual(3, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) - resultSet = getResultSet(searchText: "234 56") - XCTAssertEqual(1, resultSet.conversations.count) - XCTAssertEqual(aliceThread, resultSet.conversations.first?.thread) + threads = searchConversations(searchText: "234 56") + XCTAssertEqual(3, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) } func testSearchConversationByContactByName() { @@ -244,16 +230,16 @@ class ConversationSearcherTest: XCTestCase { XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) threads = searchConversations(searchText: "Bob") - XCTAssertEqual(2, threads.count) - XCTAssertEqual([bookClubThread, bobThread], threads) + XCTAssertEqual(1, threads.count) + XCTAssertEqual([bookClubThread], threads) threads = searchConversations(searchText: "Barker") - XCTAssertEqual(2, threads.count) - XCTAssertEqual([bookClubThread, bobThread], threads) + XCTAssertEqual(1, threads.count) + XCTAssertEqual([bookClubThread], threads) threads = searchConversations(searchText: "Bob B") - XCTAssertEqual(2, threads.count) - XCTAssertEqual([bookClubThread, bobThread], threads) + XCTAssertEqual(1, threads.count) + XCTAssertEqual([bookClubThread], threads) } func testSearchMessageByBodyContent() { diff --git a/SignalServiceKit/src/Contacts/PhoneNumber.h b/SignalServiceKit/src/Contacts/PhoneNumber.h index d98b814cf..b83ce28a0 100644 --- a/SignalServiceKit/src/Contacts/PhoneNumber.h +++ b/SignalServiceKit/src/Contacts/PhoneNumber.h @@ -35,10 +35,9 @@ - (NSURL *)toSystemDialerURL; - (NSString *)toE164; -- (NSString *)localizedDescriptionForUser; - (NSNumber *)getCountryCode; +@property (nonatomic, readonly, nullable) NSString *nationalNumber; - (BOOL)isValid; -- (BOOL)resolvesInternationallyTo:(PhoneNumber *)otherPhoneNumber; - (NSComparisonResult)compare:(PhoneNumber *)other; diff --git a/SignalServiceKit/src/Contacts/PhoneNumber.m b/SignalServiceKit/src/Contacts/PhoneNumber.m index 83b1cf8ac..767416dda 100644 --- a/SignalServiceKit/src/Contacts/PhoneNumber.m +++ b/SignalServiceKit/src/Contacts/PhoneNumber.m @@ -372,25 +372,23 @@ static NSString *const RPDefaultsKeyPhoneNumberCanonical = @"RPDefaultsKeyPhoneN return self.phoneNumber.countryCode; } -- (BOOL)isValid { - return [[PhoneNumberUtil sharedThreadLocal].nbPhoneNumberUtil isValidNumber:self.phoneNumber]; -} - -- (NSString *)localizedDescriptionForUser { - NBPhoneNumberUtil *phoneUtil = [PhoneNumberUtil sharedThreadLocal].nbPhoneNumberUtil; - - NSError *formatError = nil; - NSString *pretty = - [phoneUtil format:self.phoneNumber numberFormat:NBEPhoneNumberFormatINTERNATIONAL error:&formatError]; - - if (formatError != nil) { - return self.e164; +- (nullable NSString *)nationalNumber +{ + NSError *error; + NSString *nationalNumber = [[PhoneNumberUtil sharedThreadLocal] format:self.phoneNumber + numberFormat:NBEPhoneNumberFormatNATIONAL + error:&error]; + if (error) { + DDLogVerbose(@"%@ error parsing number into national format: %@", self.logTag, error); + return nil; } - return pretty; + + return nationalNumber; } -- (BOOL)resolvesInternationallyTo:(PhoneNumber *)otherPhoneNumber { - return [self.toE164 isEqualToString:otherPhoneNumber.toE164]; +- (BOOL)isValid +{ + return [[PhoneNumberUtil sharedThreadLocal].nbPhoneNumberUtil isValidNumber:self.phoneNumber]; } - (NSString *)description { diff --git a/SignalServiceKit/src/Storage/FullTextSearchFinder.swift b/SignalServiceKit/src/Storage/FullTextSearchFinder.swift index c1db9378b..d08e2941c 100644 --- a/SignalServiceKit/src/Storage/FullTextSearchFinder.swift +++ b/SignalServiceKit/src/Storage/FullTextSearchFinder.swift @@ -14,24 +14,39 @@ public class SearchIndexer { } public func index(_ item: T) -> String { - return indexBlock(item) + return normalize(indexingText: indexBlock(item)) + } + + private func normalize(indexingText: String) -> String { + var normalized: String = indexingText.trimmingCharacters(in: .whitespacesAndNewlines) + + // Remove any punctuation from the search index + let nonformattingScalars = normalized.unicodeScalars.lazy.filter { + !CharacterSet.punctuationCharacters.contains($0) + } + + normalized = String(String.UnicodeScalarView(nonformattingScalars)) + + return normalized } } @objc public class FullTextSearchFinder: NSObject { + // Mark: Querying + public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any, String) -> Void) { guard let ext: YapDatabaseFullTextSearchTransaction = ext(transaction: transaction) else { assertionFailure("ext was unexpectedly nil") return } - let normalized = FullTextSearchFinder.normalize(text: searchText) + let normalized = normalize(queryText: searchText) - // We want a forgiving query for phone numbers - // TODO a stricter "whole word" query for body text? - let prefixQuery = "*\(normalized)*" + // We want to match by prefix for "search as you type" functionality. + // SQLite does not support suffix or contains matches. + let prefixQuery = "\(normalized)*" let maxSearchResults = 500 var searchResultCount = 0 @@ -47,27 +62,31 @@ public class FullTextSearchFinder: NSObject { } } - private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? { - return transaction.ext(FullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction - } - - // Mark: Index Building + private func normalize(queryText: String) -> String { + var normalized: String = queryText.trimmingCharacters(in: .whitespacesAndNewlines) - private class var contactsManager: ContactsManagerProtocol { - return TextSecureKitEnv.shared().contactsManager - } - - private class func normalize(text: String) -> String { - var normalized: String = text.trimmingCharacters(in: .whitespacesAndNewlines) - - // Remove any phone number formatting from the search terms + // Remove any punctuation from the search terms let nonformattingScalars = normalized.unicodeScalars.lazy.filter { !CharacterSet.punctuationCharacters.contains($0) } + let normalizedChars = String(String.UnicodeScalarView(nonformattingScalars)) - normalized = String(String.UnicodeScalarView(nonformattingScalars)) + let digitsOnlyScalars = normalized.unicodeScalars.lazy.filter { + CharacterSet.decimalDigits.contains($0) + } + let normalizedDigits = String(String.UnicodeScalarView(digitsOnlyScalars)) - return normalized + if normalizedDigits.count > 0 { + return "\(normalizedChars) OR \(normalizedDigits)" + } else { + return "\(normalizedChars)" + } + } + + // Mark: Index Building + + private class var contactsManager: ContactsManagerProtocol { + return TextSecureKitEnv.shared().contactsManager } private static let groupThreadIndexer: SearchIndexer = SearchIndexer { (groupThread: TSGroupThread) in @@ -77,29 +96,37 @@ public class FullTextSearchFinder: NSObject { recipientIndexer.index(recipientId) }.joined(separator: " ") - let searchableContent = "\(groupName) \(memberStrings)" - - return normalize(text: searchableContent) + return "\(groupName) \(memberStrings)" } private static let contactThreadIndexer: SearchIndexer = SearchIndexer { (contactThread: TSContactThread) in let recipientId = contactThread.contactIdentifier() - let searchableContent = recipientIndexer.index(recipientId) - - return normalize(text: searchableContent) + return recipientIndexer.index(recipientId) } private static let recipientIndexer: SearchIndexer = SearchIndexer { (recipientId: String) in let displayName = contactsManager.displayName(forPhoneIdentifier: recipientId) - let searchableContent = "\(recipientId) \(displayName)" - return normalize(text: searchableContent) + let nationalNumber: String = { (recipientId: String) -> String in + + guard let phoneNumber = PhoneNumber(fromE164: recipientId) else { + assertionFailure("unexpected unparseable recipientId: \(recipientId)") + return "" + } + + guard let digitScalars = phoneNumber.nationalNumber?.unicodeScalars.filter({ CharacterSet.decimalDigits.contains($0) }) else { + assertionFailure("unexpected unparseable recipientId: \(recipientId)") + return "" + } + + return String(String.UnicodeScalarView(digitScalars)) + }(recipientId) + + return "\(recipientId) \(nationalNumber) \(displayName)" } private static let messageIndexer: SearchIndexer = SearchIndexer { (message: TSMessage) in - let searchableContent = message.body ?? "" - - return normalize(text: searchableContent) + return message.body ?? "" } private class func indexContent(object: Any) -> String? { @@ -124,8 +151,11 @@ public class FullTextSearchFinder: NSObject { // MARK: - Extension Registration - // MJK - FIXME - while developing it's helpful to rebuild the index every launch. But we need to remove this before releasing. - private static let dbExtensionName: String = "FullTextSearchFinderExtension\(Date())" + private static let dbExtensionName: String = "FullTextSearchFinderExtension)" + + private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? { + return transaction.ext(FullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction + } @objc public class func asyncRegisterDatabaseExtension(storage: OWSStorage) { @@ -138,9 +168,6 @@ public class FullTextSearchFinder: NSObject { } private class var dbExtensionConfig: YapDatabaseFullTextSearch { - // TODO is it worth doing faceted search, i.e. Author / Name / Content? - // seems unlikely that mobile users would use the "author: Alice" search syntax. - // so for now, everything searchable is jammed into a single column let contentColumnName = "content" let handler = YapDatabaseFullTextSearchHandler.withObjectBlock { (dict: NSMutableDictionary, _: String, _: String, object: Any) in