diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index a026134ad..b6a979eba 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -320,7 +320,6 @@ 45360B911F952AA900FA666C /* MarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */; }; 4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4539B5851F79348F007141FF /* PushRegistrationManager.swift */; }; 4541B71D209D3B7A0008608F /* ContactShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4541B71A209D2DAE0008608F /* ContactShareViewModel.swift */; }; - 4542DF52208B82E9007B4E76 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */; }; 4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF53208D40AC007B4E76 /* LoadingViewController.swift */; }; 45464DBC1DFA041F001D3FD6 /* DataChannelMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45464DBB1DFA041F001D3FD6 /* DataChannelMessage.swift */; }; 454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454A84032059C787008B8C75 /* MediaTileViewController.swift */; }; @@ -412,6 +411,7 @@ 45FBC5C81DF8575700E9B410 /* CallKitCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBC59A1DF8575700E9B410 /* CallKitCallManager.swift */; }; 45FBC5D11DF8592E00E9B410 /* SignalCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBC5D01DF8592E00E9B410 /* SignalCall.swift */; }; 4AC4EA13C8A444455DAB351F /* Pods_SignalMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 264242150E87D10A357DB07B /* Pods_SignalMessaging.framework */; }; + 4C20B2B720CA0034001BAC90 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */; }; 70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; }; 768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; }; 76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; }; @@ -1911,7 +1911,6 @@ 4541B719209D2D860008608F /* ViewModels */ = { isa = PBXGroup; children = ( - 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -1922,6 +1921,7 @@ 4541B71A209D2DAE0008608F /* ContactShareViewModel.swift */, 459B7759207BA3A80071D0AB /* OWSQuotedReplyModel.h */, 459B775A207BA3A80071D0AB /* OWSQuotedReplyModel.m */, + 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -3086,6 +3086,7 @@ 452EC6E1205FF5DC000E787C /* Bench.swift in Sources */, 3478506C1FD9B78A007B8332 /* NoopNotificationsManager.swift in Sources */, 34480B621FD0A98800BC14EF /* UIColor+OWS.m in Sources */, + 4C20B2B720CA0034001BAC90 /* ThreadViewModel.swift in Sources */, 34480B531FD0A7A400BC14EF /* OWSLogger.m in Sources */, 34480B641FD0A98800BC14EF /* UIView+OWS.m in Sources */, 34C3C7932040B0DD0000134C /* OWSAudioPlayer.m in Sources */, @@ -3191,7 +3192,6 @@ 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */, 34D99C931F2937CC00D284D6 /* OWSAnalytics.swift in Sources */, 340FC8B8204DAC8D007AEB0F /* AddToGroupViewController.m in Sources */, - 4542DF52208B82E9007B4E76 /* ThreadViewModel.swift in Sources */, 341F2C0F1F2B8AE700D07D6B /* DebugUIMisc.m in Sources */, 340FC8AF204DAC8D007AEB0F /* OWSLinkDeviceViewController.m in Sources */, 34E3EF0D1EFC235B007F6822 /* DebugUIDiskUsage.m in Sources */, diff --git a/Signal/test/util/SearcherTest.swift b/Signal/test/util/SearcherTest.swift index acf78653a..cdd9d8fe8 100644 --- a/Signal/test/util/SearcherTest.swift +++ b/Signal/test/util/SearcherTest.swift @@ -1,11 +1,92 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import XCTest @testable import Signal @testable import SignalMessaging +class ConversationSearcherTest: XCTestCase { + + // Mark: Dependencies + var searcher: ConversationSearcher { + return ConversationSearcher.shared + } + + var dbConnection: YapDatabaseConnection { + return OWSPrimaryStorage.shared().dbReadWriteConnection + } + + // Mark: Test Life Cycle + + override func setUp() { + super.setUp() + + ConversationFullTextSearchFinder.syncRegisterDatabaseExtension(storage: OWSPrimaryStorage.shared()) + } + + // Mark: Tests + + func testSearchByGroupName() { + + TSGroupThread.removeAllObjectsInCollection() + + var bookClubThread: ThreadViewModel! + var snackClubThread: ThreadViewModel! + self.dbConnection.readWrite { transaction in + let bookModel = TSGroupModel(title: "Book Club", memberIds: [], image: nil, groupId: Randomness.generateRandomBytes(16)) + let bookClubGroupThread = TSGroupThread.getOrCreateThread(with: bookModel, transaction: transaction) + bookClubThread = ThreadViewModel(thread: bookClubGroupThread, transaction: transaction) + + let snackModel = TSGroupModel(title: "Snack Club", memberIds: [], image: nil, groupId: Randomness.generateRandomBytes(16)) + let snackClubGroupThread = TSGroupThread.getOrCreateThread(with: snackModel, transaction: transaction) + snackClubThread = ThreadViewModel(thread: snackClubGroupThread, transaction: transaction) + } + + // No Match + let noMatch = results(searchText: "asdasdasd") + XCTAssert(noMatch.conversations.isEmpty) + + // Partial Match + let bookMatch = results(searchText: "Book") + XCTAssert(bookMatch.conversations.count == 1) + if let foundThread: ThreadViewModel = bookMatch.conversations.first?.thread { + XCTAssertEqual(bookClubThread, foundThread) + } else { + XCTFail("no thread found") + } + + let snackMatch = results(searchText: "Snack") + XCTAssert(snackMatch.conversations.count == 1) + if let foundThread: ThreadViewModel = snackMatch.conversations.first?.thread { + XCTAssertEqual(snackClubThread, foundThread) + } else { + XCTFail("no thread found") + } + + // Multiple Partial Matches + let multipleMatch = results(searchText: "Club") + XCTAssert(multipleMatch.conversations.count == 2) + XCTAssert(multipleMatch.conversations.map { $0.thread }.contains(bookClubThread)) + XCTAssert(multipleMatch.conversations.map { $0.thread }.contains(snackClubThread)) + + // Match Name Exactly + let exactMatch = results(searchText: "Book Club") + XCTAssert(exactMatch.conversations.count == 1) + XCTAssertEqual(bookClubThread, exactMatch.conversations.first!.thread) + } + + // Mark: Helpers + + private func results(searchText: String) -> ConversationSearchResults { + var results: ConversationSearchResults! + self.dbConnection.read { transaction in + results = self.searcher.results(searchText: searchText, transaction: transaction) + } + return results + } +} + class SearcherTest: XCTestCase { struct TestCharacter { @@ -62,18 +143,18 @@ class SearcherTest: XCTestCase { } func testFormattingChars() { - XCTAssert(searcher.matches(item: stinkingLizaveta, query:"323")) - XCTAssert(searcher.matches(item: stinkingLizaveta, query:"1-323-555-5555")) - XCTAssert(searcher.matches(item: stinkingLizaveta, query:"13235555555")) - XCTAssert(searcher.matches(item: stinkingLizaveta, query:"+1-323")) - XCTAssert(searcher.matches(item: stinkingLizaveta, query:"Liza +1-323")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "323")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "1-323-555-5555")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "13235555555")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "+1-323")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "Liza +1-323")) // Sanity check, match both by names - XCTAssert(searcher.matches(item: stinkingLizaveta, query:"Liza")) - XCTAssert(searcher.matches(item: regularLizaveta, query:"Liza")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "Liza")) + XCTAssert(searcher.matches(item: regularLizaveta, query: "Liza")) // Disambiguate the two Liza's by area code - XCTAssert(searcher.matches(item: stinkingLizaveta, query:"Liza 323")) - XCTAssertFalse(searcher.matches(item: regularLizaveta, query:"Liza 323")) + XCTAssert(searcher.matches(item: stinkingLizaveta, query: "Liza 323")) + XCTAssertFalse(searcher.matches(item: regularLizaveta, query: "Liza 323")) } } diff --git a/Signal/src/ViewModels/ThreadViewModel.swift b/SignalMessaging/ViewModels/ThreadViewModel.swift similarity index 83% rename from Signal/src/ViewModels/ThreadViewModel.swift rename to SignalMessaging/ViewModels/ThreadViewModel.swift index 50b00f001..80267e368 100644 --- a/Signal/src/ViewModels/ThreadViewModel.swift +++ b/SignalMessaging/ViewModels/ThreadViewModel.swift @@ -38,4 +38,13 @@ public class ThreadViewModel: NSObject { self.unreadCount = thread.unreadMessageCount(transaction: transaction) self.hasUnreadMessages = unreadCount > 0 } + + @objc + override public func isEqual(_ object: Any?) -> Bool { + guard let otherThread = object as? ThreadViewModel else { + return super.isEqual(object) + } + + return threadRecord.isEqual(otherThread.threadRecord) + } } diff --git a/SignalMessaging/utils/ConversationSearcher.swift b/SignalMessaging/utils/ConversationSearcher.swift index 4af8093b3..ea7219f9e 100644 --- a/SignalMessaging/utils/ConversationSearcher.swift +++ b/SignalMessaging/utils/ConversationSearcher.swift @@ -1,19 +1,67 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import Foundation import SignalServiceKit +@objc +public class ConversationSearchItem: NSObject { + @objc + public let thread: ThreadViewModel + + init(thread: ThreadViewModel) { + self.thread = thread + } +} + +@objc +public class ConversationSearchResults: NSObject { + let conversations: [ConversationSearchItem] + let contacts: [ConversationSearchItem] + let messages: [ConversationSearchItem] + + public init(conversations: [ConversationSearchItem], contacts: [ConversationSearchItem], messages: [ConversationSearchItem]) { + self.conversations = conversations + self.contacts = contacts + self.messages = messages + } +} + @objc public class ConversationSearcher: NSObject { + private let finder: ConversationFullTextSearchFinder + @objc public static let shared: ConversationSearcher = ConversationSearcher() override private init() { + finder = ConversationFullTextSearchFinder() super.init() } + @objc + public func results(searchText: String, transaction: YapDatabaseReadTransaction) -> ConversationSearchResults { + + // TODO limit results, prioritize conversations, then contacts, then messages. + var conversations: [ConversationSearchItem] = [] + var contacts: [ConversationSearchItem] = [] + var messages: [ConversationSearchItem] = [] + + self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any) in + if let thread = match as? TSThread { + let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) + let searchItem = ConversationSearchItem(thread: threadViewModel) + + conversations.append(searchItem) + } else { + Logger.debug("\(self.logTag) in \(#function) unhandled item: \(match)") + } + } + + return ConversationSearchResults(conversations: conversations, contacts: contacts, messages: messages) + } + @objc(filterThreads:withSearchText:) public func filterThreads(_ threads: [TSThread], searchText: String) -> [TSThread] { guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { @@ -58,6 +106,7 @@ public class ConversationSearcher: NSObject { // MARK: - Helpers // MARK: Searchers + private lazy var groupThreadSearcher: Searcher = Searcher { (groupThread: TSGroupThread) in let groupName = groupThread.groupModel.groupName let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in @@ -88,3 +137,50 @@ public class ConversationSearcher: NSObject { return "\(recipientId) \(contactName) \(profileName ?? "")" } } + +public class ConversationFullTextSearchFinder { + + public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any) -> Void) { + guard let ext = ext(transaction: transaction) else { + owsFail("ext was unexpectedly nil") + return + } + + ext.enumerateKeysAndObjects(matching: searchText) { (_, _, object, _) in + block(object) + } + } + + private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? { + return transaction.ext(ConversationFullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction + } + + // MARK: - Extension Registration + + static let dbExtensionName: String = "ConversationFullTextSearchFinderExtension1" + + public class func asyncRegisterDatabaseExtension(storage: OWSStorage) { + storage.asyncRegister(dbExtensionConfig, withName: dbExtensionName) + } + + // Only for testing. + public class func syncRegisterDatabaseExtension(storage: OWSStorage) { + storage.register(dbExtensionConfig, withName: dbExtensionName) + } + + private class var dbExtensionConfig: YapDatabaseFullTextSearch { + let contentColumnName = "content" + let handler = YapDatabaseFullTextSearchHandler.withObjectBlock { (dict: NSMutableDictionary, _: String, _: String, object: Any) in + if let groupThread = object as? TSGroupThread { + dict[contentColumnName] = groupThread.groupModel.groupName + } + } + + // update search index on contact name changes? + // update search index on message insertion? + + // TODO is it worth doing faceted search, i.e. Author / Name / Content? + // seems unlikely that mobile users would use the "author: Alice" search syntax. + return YapDatabaseFullTextSearch(columnNames: ["content"], handler: handler) + } +}