diff --git a/Podfile.lock b/Podfile.lock index 536a55d2f..7e4dd9918 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -228,7 +228,7 @@ SPEC CHECKSUMS: PureLayout: 4d550abe49a94f24c2808b9b95db9131685fe4cd Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c - SignalServiceKit: 5cc6e8e249f381c5eaee8693c0dff20fc1a3eee0 + SignalServiceKit: 3a65a39b6671c290e6258db78002527e085842ad SocketRocket: dbb1554b8fc288ef8ef370d6285aeca7361be31e SQLCipher: f9fcf29b2e59ced7defc2a2bdd0ebe79b40d4990 SSZipArchive: d4009d2ce5520a421f231fd97028cc0e2667eed8 diff --git a/Pods b/Pods index 4fa9dbed3..47c8a2611 160000 --- a/Pods +++ b/Pods @@ -1 +1 @@ -Subproject commit 4fa9dbed3419fc81f5afbf17aa1e35d62656c72e +Subproject commit 47c8a2611481201e17387b4335421f373f8c8e9b diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index a026134ad..4dcc26b51 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,8 @@ 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 */; }; + 4C20B2B920CA10DE001BAC90 /* ConversationSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.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 */; }; @@ -1065,6 +1066,7 @@ 45FBC59A1DF8575700E9B410 /* CallKitCallManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallKitCallManager.swift; sourceTree = ""; }; 45FBC5D01DF8592E00E9B410 /* SignalCall.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalCall.swift; sourceTree = ""; }; 45FDA43420A4D22700396358 /* OWSNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSNavigationBar.swift; sourceTree = ""; }; + 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearchViewController.swift; sourceTree = ""; }; 69349DE607F5BA6036C9AC60 /* Pods-SignalShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SignalShareExtension/Pods-SignalShareExtension.debug.xcconfig"; sourceTree = ""; }; 70377AAA1918450100CAF501 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; 748A5CAEDD7C919FC64C6807 /* Pods_SignalTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1408,6 +1410,7 @@ 34386A50207D0C01009F5D9C /* HomeViewCell.m */, 34386A4F207D0C01009F5D9C /* HomeViewController.h */, 34386A4D207D0C01009F5D9C /* HomeViewController.m */, + 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */, ); path = HomeView; sourceTree = ""; @@ -1911,7 +1914,6 @@ 4541B719209D2D860008608F /* ViewModels */ = { isa = PBXGroup; children = ( - 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -1922,6 +1924,7 @@ 4541B71A209D2DAE0008608F /* ContactShareViewModel.swift */, 459B7759207BA3A80071D0AB /* OWSQuotedReplyModel.h */, 459B775A207BA3A80071D0AB /* OWSQuotedReplyModel.m */, + 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -3086,6 +3089,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 +3195,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 */, @@ -3214,6 +3217,7 @@ 45794E861E00620000066731 /* CallUIAdapter.swift in Sources */, 340FC8BA204DAC8D007AEB0F /* FingerprintViewScanController.m in Sources */, 4585C4681ED8F8D200896AEA /* SafetyNumberConfirmationAlert.swift in Sources */, + 4C20B2B920CA10DE001BAC90 /* ConversationSearchViewController.swift in Sources */, 450D19131F85236600970622 /* RemoteVideoView.m in Sources */, B6B9ECFC198B31BA00C620D3 /* PushManager.m in Sources */, 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */, diff --git a/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift new file mode 100644 index 000000000..0e6f14efc --- /dev/null +++ b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift @@ -0,0 +1,244 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc +class ConversationSearchViewController: UITableViewController { + + var searchResultSet: SearchResultSet = SearchResultSet.empty + + var uiDatabaseConnection: YapDatabaseConnection { + // TODO do we want to respond to YapDBModified? Might be hard when there's lots of search results, for only marginal value + return OWSPrimaryStorage.shared().uiDatabaseConnection + } + + var searcher: ConversationSearcher { + return ConversationSearcher.shared + } + + enum SearchSection: Int { + case conversations = 0 + case contacts = 1 + case messages = 2 + } + + // MARK: View Lifecyle + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.rowHeight = UITableViewAutomaticDimension + tableView.estimatedRowHeight = 60 + + tableView.register(ConversationSearchResultCell.self, forCellReuseIdentifier: ConversationSearchResultCell.reuseIdentifier) + tableView.register(MessageSearchResultCell.self, forCellReuseIdentifier: MessageSearchResultCell.reuseIdentifier) + } + + // MARK: UITableViewDataSource + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let searchSection = SearchSection(rawValue: section) else { + owsFail("unknown section: \(section)") + return 0 + } + + switch searchSection { + case .conversations: + return searchResultSet.conversations.count + case .contacts: + return searchResultSet.contacts.count + case .messages: + return searchResultSet.messages.count + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + guard let searchSection = SearchSection(rawValue: indexPath.section) else { + return UITableViewCell() + } + + switch searchSection { + case .conversations: + guard let cell = tableView.dequeueReusableCell(withIdentifier: ConversationSearchResultCell.reuseIdentifier) as? ConversationSearchResultCell else { + return UITableViewCell() + } + + guard let searchResult = self.searchResultSet.conversations[safe: indexPath.row] else { + return UITableViewCell() + } + cell.configure(searchResult: searchResult) + return cell + case .contacts: + // TODO + return UITableViewCell() + case .messages: + guard let cell = tableView.dequeueReusableCell(withIdentifier: MessageSearchResultCell.reuseIdentifier) as? MessageSearchResultCell else { + return UITableViewCell() + } + + guard let searchResult = self.searchResultSet.messages[safe: indexPath.row] else { + return UITableViewCell() + } + + cell.configure(searchResult: searchResult) + return cell + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return 3 + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard let searchSection = SearchSection(rawValue: section) else { + owsFail("unknown section: \(section)") + return nil + } + + switch searchSection { + case .conversations: + if searchResultSet.conversations.count > 0 { + return NSLocalizedString("SEARCH_SECTION_CONVERSATIONS", comment: "section header for search results that match existing conversations (either group or contact conversations)") + } else { + return nil + } + case .contacts: + if searchResultSet.contacts.count > 0 { + return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "section header for search results that match a contact who doesn't have an existing conversation") + } else { + return nil + } + case .messages: + if searchResultSet.messages.count > 0 { + return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "section header for search results that match a message in a conversation") + } else { + return nil + } + } + } + + // MARK: UISearchBarDelegate + + @objc + public func updateSearchResults(searchText: String) { + guard searchText.stripped.count > 0 else { + self.searchResultSet = SearchResultSet.empty + return + } + + // TODO: async? + // TODO: debounce? + + self.uiDatabaseConnection.read { transaction in + self.searchResultSet = self.searcher.results(searchText: searchText, transaction: transaction) + } + + // TODO: more perfomant way to do this? + self.tableView.reloadData() + } +} + +class ConversationSearchResultCell: UITableViewCell { + static let reuseIdentifier = "ConversationSearchResultCell" + + let nameLabel: UILabel + let snippetLabel: UILabel + let avatarView: AvatarImageView + let avatarWidth: UInt = 40 + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + self.nameLabel = UILabel() + self.snippetLabel = UILabel() + self.avatarView = AvatarImageView() + avatarView.autoSetDimensions(to: CGSize(width: CGFloat(avatarWidth), height: CGFloat(avatarWidth))) + + super.init(style: style, reuseIdentifier: reuseIdentifier) + + nameLabel.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight() + snippetLabel.font = UIFont.ows_dynamicTypeFootnote + + let textRows = UIStackView(arrangedSubviews: [nameLabel, snippetLabel]) + textRows.axis = .vertical + + let columns = UIStackView(arrangedSubviews: [avatarView, textRows]) + columns.axis = .horizontal + columns.spacing = 8 + + contentView.addSubview(columns) + columns.autoPinEdgesToSuperviewMargins() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + var contactsManager: OWSContactsManager { + return Environment.current().contactsManager + } + + func configure(searchResult: SearchResult) { + self.avatarView.image = OWSAvatarBuilder.buildImage(thread: searchResult.thread.threadRecord, diameter: avatarWidth, contactsManager: self.contactsManager) + self.nameLabel.text = searchResult.thread.name + self.snippetLabel.text = searchResult.snippet + } +} + +class MessageSearchResultCell: UITableViewCell { + static let reuseIdentifier = "MessageSearchResultCell" + + let nameLabel: UILabel + let snippetLabel: UILabel + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + self.nameLabel = UILabel() + self.snippetLabel = UILabel() + + super.init(style: style, reuseIdentifier: reuseIdentifier) + + nameLabel.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight() + snippetLabel.font = UIFont.ows_dynamicTypeFootnote + + let textRows = UIStackView(arrangedSubviews: [nameLabel, snippetLabel]) + textRows.axis = .vertical + + contentView.addSubview(textRows) + textRows.autoPinEdgesToSuperviewMargins() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(searchResult: SearchResult) { + self.nameLabel.text = searchResult.thread.name + + guard let snippet = searchResult.snippet else { + self.snippetLabel.text = nil + return + } + + guard let encodedString = snippet.data(using: .utf8) else { + self.snippetLabel.text = nil + return + } + + // Bold snippet text + do { + + // FIXME - The snippet marks up the matched search text with tags. + // We can parse this into an attributed string, but it also takes on an undesirable font. + // We want to apply our own font without clobbering bold in the process - maybe by enumerating and inspecting the attributes? Or maybe we can pass in a base font? + let attributedSnippet = try NSMutableAttributedString(data: encodedString, + options: [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html], + documentAttributes: nil) + attributedSnippet.addAttribute(NSAttributedStringKey.font, value: self.snippetLabel.font, range: NSRange(location: 0, length: attributedSnippet.length)) + + self.snippetLabel.attributedText = attributedSnippet + } catch { + owsFail("failed to generate snippet: \(error)") + } + } +} diff --git a/Signal/src/ViewControllers/HomeView/HomeViewController.m b/Signal/src/ViewControllers/HomeView/HomeViewController.m index 27a72a5ef..d39ebf4cb 100644 --- a/Signal/src/ViewControllers/HomeView/HomeViewController.m +++ b/Signal/src/ViewControllers/HomeView/HomeViewController.m @@ -38,7 +38,7 @@ typedef NS_ENUM(NSInteger, HomeViewMode) { NSString *const kArchivedConversationsReuseIdentifier = @"kArchivedConversationsReuseIdentifier"; -@interface HomeViewController () +@interface HomeViewController () @property (nonatomic) UITableView *tableView; @property (nonatomic) UILabel *emptyBoxLabel; @@ -54,6 +54,11 @@ NSString *const kArchivedConversationsReuseIdentifier = @"kArchivedConversations @property (nonatomic) BOOL shouldObserveDBModifications; @property (nonatomic) BOOL hasBeenPresented; +// Mark: Search + +@property (nonatomic) UISearchController *searchController; +@property (nonatomic) ConversationSearchViewController *searchResultsController; + // Dependencies @property (nonatomic, readonly) AccountManager *accountManager; @@ -238,7 +243,7 @@ NSString *const kArchivedConversationsReuseIdentifier = @"kArchivedConversations action:@selector(pullToRefreshPerformed:) forControlEvents:UIControlEventValueChanged]; [self.tableView insertSubview:pullToRefreshView atIndex:0]; - + [self updateReminderViews]; } @@ -261,7 +266,7 @@ NSString *const kArchivedConversationsReuseIdentifier = @"kArchivedConversations [super viewDidLoad]; self.editingDbConnection = OWSPrimaryStorage.sharedManager.newDatabaseConnection; - + // Create the database connection. [self uiDatabaseConnection]; @@ -289,7 +294,18 @@ NSString *const kArchivedConversationsReuseIdentifier = @"kArchivedConversations && (self.traitCollection.forceTouchCapability == UIForceTouchCapabilityAvailable)) { [self registerForPreviewingWithDelegate:self sourceView:self.tableView]; } - + + // Search + + // Setting tableHeader calls numberOfSections, which must happen after updateMappings has been called at least once. + ConversationSearchViewController *searchResultsController = [ConversationSearchViewController new]; + self.searchResultsController = searchResultsController; + UISearchController *searchController = [[UISearchController alloc] initWithSearchResultsController:searchResultsController]; + self.searchController = searchController; + searchController.searchResultsUpdater = self; + self.tableView.tableHeaderView = self.searchController.searchBar; + self.definesPresentationContext = YES; + [self updateBarButtonItems]; } @@ -843,6 +859,13 @@ NSString *const kArchivedConversationsReuseIdentifier = @"kArchivedConversations return YES; } +#pragma mark - SearchResultsUpdating + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController +{ + [self.searchResultsController updateSearchResultsWithSearchText:self.searchController.searchBar.text]; +} + #pragma mark - HomeFeedTableViewCellDelegate - (void)tableViewCellTappedDelete:(NSIndexPath *)indexPath diff --git a/Signal/test/Models/MesssagesBubblesSizeCalculatorTest.swift b/Signal/test/Models/MesssagesBubblesSizeCalculatorTest.swift index cbcd4af54..30aa5e9b7 100644 --- a/Signal/test/Models/MesssagesBubblesSizeCalculatorTest.swift +++ b/Signal/test/Models/MesssagesBubblesSizeCalculatorTest.swift @@ -10,7 +10,7 @@ import SignalServiceKit * This is a brittle test, which will break if our layout changes. * * It serves mostly as documentation for cases to consider when changing the cell measurement logic. - * Primarly these test cases came out of a bug introduced in iOS10, + * Primarily these test cases came out of a bug introduced in iOS10, * which prevents us from computing proper bounding box for text that uses the UIEmoji font. * * If one of these tests breaks, it should be OK to update the expected value so long as you've tested the result renders @@ -48,70 +48,70 @@ class MesssagesBubblesSizeCalculatorTest: XCTestCase { let text: String? = "" let viewItem = self.viewItemForText(text) let actual = messageBubbleSize(for: viewItem) - XCTAssertEqual(42, actual.height) + XCTAssertEqual(36, actual.height) } func testHeightForShort1LineMessage() { let text = "foo" let viewItem = self.viewItemForText(text) let actual = messageBubbleSize(for: viewItem) - XCTAssertEqual(42, actual.height) + XCTAssertEqual(36, actual.height) } func testHeightForLong1LineMessage() { let text = "1 2 3 4 5 6 7 8 9 10 11 12 13 14 x" let viewItem = self.viewItemForText(text) let actual = messageBubbleSize(for: viewItem) - XCTAssertEqual(64, actual.height) + XCTAssertEqual(58, actual.height) } func testHeightForShort2LineMessage() { let text = "1 2 3 4 5 6 7 8 9 10 11 12 13 14 x 1" let viewItem = self.viewItemForText(text) let actual = messageBubbleSize(for: viewItem) - XCTAssertEqual(64, actual.height) + XCTAssertEqual(58, actual.height) } func testHeightForLong2LineMessage() { let text = "1 2 3 4 5 6 7 8 9 10 11 12 13 14 x 1 2 3 4 5 6 7 8 9 10 11 12 13 14 x" let viewItem = self.viewItemForText(text) let actual = messageBubbleSize(for: viewItem) - XCTAssertEqual(86, actual.height) + XCTAssertEqual(80, actual.height) } func testHeightForiOS10EmojiBug() { let viewItem = self.viewItemForText("Wunderschönen Guten Morgaaaahhhn 😝 - hast du gut geschlafen ☺️😘") let actual = messageBubbleSize(for: viewItem) - XCTAssertEqual(86, actual.height) + XCTAssertEqual(80, actual.height) } func testHeightForiOS10EmojiBug2() { let viewItem = self.viewItemForText("Test test test test test test test test test test test test 😊❤️❤️") let actual = messageBubbleSize(for: viewItem) - XCTAssertEqual(86, actual.height) + XCTAssertEqual(80, actual.height) } func testHeightForChineseWithEmojiBug() { let viewItem = self.viewItemForText("一二三四五六七八九十甲乙丙😝戊己庚辛壬圭咖啡牛奶餅乾水果蛋糕") let actual = messageBubbleSize(for: viewItem) // erroneously seeing 69 with the emoji fix in place. - XCTAssertEqual(86, actual.height) + XCTAssertEqual(80, actual.height) } func testHeightForChineseWithoutEmojiBug() { let viewItem = self.viewItemForText("一二三四五六七八九十甲乙丙丁戊己庚辛壬圭咖啡牛奶餅乾水果蛋糕") let actual = messageBubbleSize(for: viewItem) // erroneously seeing 69 with the emoji fix in place. - XCTAssertEqual(86, actual.height) + XCTAssertEqual(80, actual.height) } func testHeightForiOS10DoubleSpaceNumbersBug() { let viewItem = self.viewItemForText("12345678901234567890") let actual = messageBubbleSize(for: viewItem) // erroneously seeing 51 with emoji fix in place. It's the call to "fix string" - XCTAssertEqual(64, actual.height) + XCTAssertEqual(58, actual.height) } } diff --git a/Signal/test/util/SearcherTest.swift b/Signal/test/util/SearcherTest.swift index acf78653a..7983082b9 100644 --- a/Signal/test/util/SearcherTest.swift +++ b/Signal/test/util/SearcherTest.swift @@ -1,11 +1,290 @@ // -// 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 +@objc +class FakeEnvironment: TextSecureKitEnv { + let proxy: TextSecureKitEnv + + init(proxy: TextSecureKitEnv) { + self.proxy = proxy + super.init(callMessageHandler: proxy.callMessageHandler, contactsManager: proxy.contactsManager, messageSender: proxy.messageSender, notificationsManager: proxy.notificationsManager, profileManager: proxy.profileManager) + } + + var stubbedCallMessageHandler: OWSCallMessageHandler? + override var callMessageHandler: OWSCallMessageHandler { + if let callMessageHandler = stubbedCallMessageHandler { + return callMessageHandler + } + return proxy.callMessageHandler + } + + var stubbedContactsManager: ContactsManagerProtocol? + override var contactsManager: ContactsManagerProtocol { + if let contactsManager = stubbedContactsManager { + return contactsManager + } + return proxy.contactsManager + } + + var stubbedMessageSender: MessageSender? + override var messageSender: MessageSender { + if let messageSender = stubbedMessageSender { + return messageSender + } + return proxy.messageSender + } + + var stubbedNotificationsManager: NotificationsProtocol? + override var notificationsManager: NotificationsProtocol { + if let notificationsManager = stubbedNotificationsManager { + return notificationsManager + } + return proxy.notificationsManager + } + + var stubbedProfileManager: ProfileManagerProtocol? + override var profileManager: ProfileManagerProtocol { + if let profileManager = stubbedProfileManager { + return profileManager + } + return proxy.profileManager + } +} + +@objc +class FakeContactsManager: NSObject, ContactsManagerProtocol { + func displayName(forPhoneIdentifier phoneNumber: String?) -> String { + if phoneNumber == "+12345678900" { + return "Alice" + } else if phoneNumber == "+49030183000" { + return "Bob Barker" + } else { + return "" + } + } + + func signalAccounts() -> [SignalAccount] { + return [] + } + + func isSystemContact(_ recipientId: String) -> Bool { + return true + } + + func isSystemContact(withSignalAccount recipientId: String) -> Bool { + return true + } +} + +class ConversationSearcherTest: XCTestCase { + + // MARK: - Dependencies + var searcher: ConversationSearcher { + return ConversationSearcher.shared + } + + var dbConnection: YapDatabaseConnection { + return OWSPrimaryStorage.shared().dbReadWriteConnection + } + + // MARK: - Test Life Cycle + + var originalEnvironment: TextSecureKitEnv? + + override func tearDown() { + super.tearDown() + + TextSecureKitEnv.setShared(originalEnvironment!) + } + + override func setUp() { + super.setUp() + + FullTextSearchFinder.syncRegisterDatabaseExtension(storage: OWSPrimaryStorage.shared()) + + TSContactThread.removeAllObjectsInCollection() + TSGroupThread.removeAllObjectsInCollection() + TSMessage.removeAllObjectsInCollection() + + originalEnvironment = TextSecureKitEnv.shared() + + let testEnvironment: FakeEnvironment = FakeEnvironment(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 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 snackClubGroupThread = TSGroupThread.getOrCreateThread(with: snackModel, transaction: transaction) + self.snackClubThread = ThreadViewModel(thread: snackClubGroupThread, transaction: transaction) + + let aliceContactThread = TSContactThread.getOrCreateThread(withContactId: "+12345678900", 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 helloAlice = TSOutgoingMessage(in: aliceContactThread, messageBody: "Hello Alice", attachmentId: nil) + helloAlice.save(with: transaction) + + let goodbyeAlice = TSOutgoingMessage(in: aliceContactThread, messageBody: "Goodbye Alice", attachmentId: nil) + goodbyeAlice.save(with: transaction) + + let helloBookClub = TSOutgoingMessage(in: bookClubGroupThread, messageBody: "Hello Book Club", attachmentId: nil) + helloBookClub.save(with: transaction) + + let goodbyeBookClub = TSOutgoingMessage(in: bookClubGroupThread, messageBody: "Goodbye Book Club", attachmentId: nil) + goodbyeBookClub.save(with: transaction) + } + } + + // MARK: - Fixtures + + var bookClubThread: ThreadViewModel! + var snackClubThread: ThreadViewModel! + + var aliceThread: ThreadViewModel! + var bobThread: ThreadViewModel! + + // MARK: Tests + + func testSearchByGroupName() { + var threads: [ThreadViewModel] = [] + + // No Match + threads = searchConversations(searchText: "asdasdasd") + XCTAssert(threads.isEmpty) + + // Partial Match + threads = searchConversations(searchText: "Book") + XCTAssertEqual(1, threads.count) + XCTAssertEqual([bookClubThread], threads) + + threads = searchConversations(searchText: "Snack") + XCTAssertEqual(1, threads.count) + XCTAssertEqual([snackClubThread], threads) + + // Multiple Partial Matches + threads = searchConversations(searchText: "Club") + XCTAssertEqual(2, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread], threads) + + // Match Name Exactly + threads = searchConversations(searchText: "Book Club") + XCTAssertEqual(1, threads.count) + XCTAssertEqual([bookClubThread], threads) + } + + func testSearchContactByNumber() { + var threads: [ThreadViewModel] = [] + + // No match + threads = searchConversations(searchText: "+5551239999") + XCTAssertEqual(0, threads.count) + + // Exact match + threads = searchConversations(searchText: "+12345678900") + XCTAssertEqual(3, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) + + // Partial match + threads = searchConversations(searchText: "+123456") + XCTAssertEqual(3, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) + + // Prefixes + threads = searchConversations(searchText: "12345678900") + XCTAssertEqual(3, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) + + threads = searchConversations(searchText: "49") + XCTAssertEqual(2, threads.count) + XCTAssertEqual([bookClubThread, bobThread], threads) + + threads = searchConversations(searchText: "1-234-56") + XCTAssertEqual(3, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) + + threads = searchConversations(searchText: "123456") + XCTAssertEqual(3, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) + + threads = searchConversations(searchText: "1.234.56") + XCTAssertEqual(3, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) + } + + // TODO + func pending_testSearchContactByNumber() { + var resultSet: SearchResultSet = .empty + + // Phone Number formatting should be forgiving + resultSet = getResultSet(searchText: "234.56") + XCTAssertEqual(1, resultSet.conversations.count) + XCTAssertEqual(aliceThread, resultSet.conversations.first?.thread) + + resultSet = getResultSet(searchText: "234 56") + XCTAssertEqual(1, resultSet.conversations.count) + XCTAssertEqual(aliceThread, resultSet.conversations.first?.thread) + } + + func testSearchConversationByContactByName() { + var threads: [ThreadViewModel] = [] + + threads = searchConversations(searchText: "Alice") + XCTAssertEqual(3, threads.count) + XCTAssertEqual([bookClubThread, snackClubThread, aliceThread], threads) + + threads = searchConversations(searchText: "Bob") + XCTAssertEqual(2, threads.count) + XCTAssertEqual([bookClubThread, bobThread], threads) + + threads = searchConversations(searchText: "Barker") + XCTAssertEqual(2, threads.count) + XCTAssertEqual([bookClubThread, bobThread], threads) + + threads = searchConversations(searchText: "Bob B") + XCTAssertEqual(2, threads.count) + XCTAssertEqual([bookClubThread, bobThread], threads) + } + + func testSearchMessageByBodyContent() { + var resultSet: SearchResultSet = .empty + + resultSet = getResultSet(searchText: "Hello Alice") + XCTAssertEqual(1, resultSet.messages.count) + XCTAssertEqual(aliceThread, resultSet.messages.first?.thread) + + resultSet = getResultSet(searchText: "Hello") + XCTAssertEqual(2, resultSet.messages.count) + XCTAssert(resultSet.messages.map { $0.thread }.contains(aliceThread)) + XCTAssert(resultSet.messages.map { $0.thread }.contains(bookClubThread)) + } + + // Mark: Helpers + + private func searchConversations(searchText: String) -> [ThreadViewModel] { + let results = getResultSet(searchText: searchText) + return results.conversations.map { $0.thread } + } + + private func getResultSet(searchText: String) -> SearchResultSet { + var results: SearchResultSet! + self.dbConnection.read { transaction in + results = self.searcher.results(searchText: searchText, transaction: transaction) + } + return results + } +} + class SearcherTest: XCTestCase { struct TestCharacter { @@ -62,18 +341,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/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 785fb8d3b..10a9e08af 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -986,6 +986,9 @@ /* A label for conversations with blocked users. */ "HOME_VIEW_BLOCKED_CONTACT_CONVERSATION" = "Blocked"; +/* Placeholder text for search bar which filters conversations. */ +"HOME_VIEW_CONVERSATION_SEARCHBAR_PLACEHOLDER" = "Search"; + /* Title for the home view's 'archive' mode. */ "HOME_VIEW_TITLE_ARCHIVE" = "Archive"; @@ -1750,6 +1753,15 @@ /* No comment provided by engineer. */ "SEARCH_BYNAMEORNUMBER_PLACEHOLDER_TEXT" = "Search by name or number"; +/* section header for search results that match a contact who doesn't have an existing conversation */ +"SEARCH_SECTION_CONTACTS" = "Other Contacts"; + +/* section header for search results that match existing conversations (either group or contact conversations) */ +"SEARCH_SECTION_CONVERSATIONS" = "Conversations"; + +/* section header for search results that match a message in a conversation */ +"SEARCH_SECTION_MESSAGES" = "Messages"; + /* No comment provided by engineer. */ "SECURE_SESSION_RESET" = "Secure session was reset."; 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/categories/String+OWS.swift b/SignalMessaging/categories/String+OWS.swift index 275a318e1..1b56f959b 100644 --- a/SignalMessaging/categories/String+OWS.swift +++ b/SignalMessaging/categories/String+OWS.swift @@ -6,8 +6,12 @@ import Foundation public extension String { + var stripped: String { + return self.trimmingCharacters(in: .whitespacesAndNewlines) + } + // Truncates string to be less than or equal to byteCount, while ensuring we never truncate partial characters for multibyte characters. - public func truncated(toByteCount byteCount: UInt) -> String? { + func truncated(toByteCount byteCount: UInt) -> String? { var lowerBoundCharCount = 0 var upperBoundCharCount = self.count diff --git a/SignalMessaging/utils/ConversationSearcher.swift b/SignalMessaging/utils/ConversationSearcher.swift index 4af8093b3..644bb2195 100644 --- a/SignalMessaging/utils/ConversationSearcher.swift +++ b/SignalMessaging/utils/ConversationSearcher.swift @@ -1,19 +1,77 @@ // -// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import Foundation import SignalServiceKit +public class SearchResult { + public let thread: ThreadViewModel + public let snippet: String? + + init(thread: ThreadViewModel, snippet: String?) { + self.thread = thread + self.snippet = snippet + } +} + +public class SearchResultSet { + public let conversations: [SearchResult] + public let contacts: [SearchResult] + public let messages: [SearchResult] + + public init(conversations: [SearchResult], contacts: [SearchResult], messages: [SearchResult]) { + self.conversations = conversations + self.contacts = contacts + self.messages = messages + } + + public class var empty: SearchResultSet { + return SearchResultSet(conversations: [], contacts: [], messages: []) + } +} + @objc public class ConversationSearcher: NSObject { + private let finder: FullTextSearchFinder + @objc public static let shared: ConversationSearcher = ConversationSearcher() override private init() { + finder = FullTextSearchFinder() super.init() } + public func results(searchText: String, transaction: YapDatabaseReadTransaction) -> SearchResultSet { + var conversations: [SearchResult] = [] + var contacts: [SearchResult] = [] + var messages: [SearchResult] = [] + + self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any, snippet: String?) in + if let thread = match as? TSThread { + let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) + let snippet: String? = thread.lastMessageText(transaction: transaction) + let searchResult = SearchResult(thread: threadViewModel, snippet: snippet) + + conversations.append(searchResult) + } else if let message = match as? TSMessage { + let thread = message.thread(with: transaction) + + let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) + let searchResult = SearchResult(thread: threadViewModel, snippet: snippet) + + messages.append(searchResult) + } else if let signalAccount = match as? SignalAccount { + // TODO show "other contact" results when there is no existing thread + } else { + Logger.debug("\(self.logTag) in \(#function) unhandled item: \(match)") + } + } + + return SearchResultSet(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 +116,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 diff --git a/SignalServiceKit.podspec b/SignalServiceKit.podspec index 0d67ba3c2..461ebc290 100644 --- a/SignalServiceKit.podspec +++ b/SignalServiceKit.podspec @@ -25,7 +25,7 @@ An Objective-C library for communicating with the Signal messaging service. #s.ios.deployment_target = '9.0' #s.osx.deployment_target = '10.9' s.requires_arc = true - s.source_files = 'SignalServiceKit/src/**/*.{h,m,mm}' + s.source_files = 'SignalServiceKit/src/**/*.{h,m,mm,swift}' # We want to use modules to avoid clobbering CocoaLumberjack macros defined # by other OWS modules which *also* import CocoaLumberjack. But because we diff --git a/SignalServiceKit/src/Storage/FullTextSearchFinder.swift b/SignalServiceKit/src/Storage/FullTextSearchFinder.swift new file mode 100644 index 000000000..5019d5fdf --- /dev/null +++ b/SignalServiceKit/src/Storage/FullTextSearchFinder.swift @@ -0,0 +1,149 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +// Create a searchable index for objects of type T +public class SearchIndexer { + + private let indexBlock: (T) -> String + + public init(indexBlock: @escaping (T) -> String) { + self.indexBlock = indexBlock + } + + public func index(_ item: T) -> String { + return indexBlock(item) + } +} + +@objc +public class FullTextSearchFinder: NSObject { + + 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) + + // We want a forgiving query for phone numbers + // TODO a stricter "whole word" query for body text? + let prefixQuery = "*\(normalized)*" + + let maxSearchResults = 500 + var searchResultCount = 0 + // (snippet: String, collection: String, key: String, object: Any, stop: UnsafeMutablePointer) + ext.enumerateKeysAndObjects(matching: prefixQuery, with: nil) { (snippet: String, _: String, _: String, object: Any, stop: UnsafeMutablePointer) in + guard searchResultCount < maxSearchResults else { + stop.pointee = true + return + } + searchResultCount = searchResultCount + 1 + + block(object, snippet) + } + } + + private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? { + return transaction.ext(FullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction + } + + // Mark: Index Building + + 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 + let nonformattingScalars = normalized.unicodeScalars.lazy.filter { + !CharacterSet.punctuationCharacters.contains($0) + } + + normalized = String(String.UnicodeScalarView(nonformattingScalars)) + + return normalized + } + + private static let groupThreadIndexer: SearchIndexer = SearchIndexer { (groupThread: TSGroupThread) in + let groupName = groupThread.groupModel.groupName ?? "" + + let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in + recipientIndexer.index(recipientId) + }.joined(separator: " ") + + let searchableContent = "\(groupName) \(memberStrings)" + + return normalize(text: searchableContent) + } + + private static let contactThreadIndexer: SearchIndexer = SearchIndexer { (contactThread: TSContactThread) in + let recipientId = contactThread.contactIdentifier() + let searchableContent = recipientIndexer.index(recipientId) + + return normalize(text: searchableContent) + } + + private static let recipientIndexer: SearchIndexer = SearchIndexer { (recipientId: String) in + let displayName = contactsManager.displayName(forPhoneIdentifier: recipientId) + let searchableContent = "\(recipientId) \(displayName)" + + return normalize(text: searchableContent) + } + + private static let messageIndexer: SearchIndexer = SearchIndexer { (message: TSMessage) in + let searchableContent = message.body ?? "" + + return normalize(text: searchableContent) + } + + private class func indexContent(object: Any) -> String? { + if let groupThread = object as? TSGroupThread { + return self.groupThreadIndexer.index(groupThread) + } else if let contactThread = object as? TSContactThread { + return self.contactThreadIndexer.index(contactThread) + } else if let message = object as? TSMessage { + return self.messageIndexer.index(message) + } else { + return nil + } + } + + // 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())" + + @objc + 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 { + // 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 + if let content: String = indexContent(object: object) { + dict[contentColumnName] = content + } + } + + // update search index on contact name changes? + // update search index on message insertion? + + return YapDatabaseFullTextSearch(columnNames: ["content"], handler: handler) + } +} diff --git a/SignalServiceKit/src/Storage/OWSPrimaryStorage.m b/SignalServiceKit/src/Storage/OWSPrimaryStorage.m index c30519dd7..967789040 100644 --- a/SignalServiceKit/src/Storage/OWSPrimaryStorage.m +++ b/SignalServiceKit/src/Storage/OWSPrimaryStorage.m @@ -16,6 +16,7 @@ #import "OWSStorage+Subclass.h" #import "TSDatabaseSecondaryIndexes.h" #import "TSDatabaseView.h" +#import NS_ASSUME_NONNULL_BEGIN @@ -58,13 +59,14 @@ void RunAsyncRegistrationsForStorage(OWSStorage *storage, dispatch_block_t compl [TSDatabaseView asyncRegisterThreadOutgoingMessagesDatabaseView:storage]; [TSDatabaseView asyncRegisterThreadSpecialMessagesDatabaseView:storage]; - // Register extensions which aren't essential for rendering threads async. + [FullTextSearchFinder asyncRegisterDatabaseExtensionWithStorage:storage]; [OWSIncomingMessageFinder asyncRegisterExtensionWithPrimaryStorage:storage]; [TSDatabaseView asyncRegisterSecondaryDevicesDatabaseView:storage]; [OWSDisappearingMessagesFinder asyncRegisterDatabaseExtensions:storage]; [OWSFailedMessagesJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage]; [OWSFailedAttachmentDownloadsJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage]; [OWSMediaGalleryFinder asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage]; + // NOTE: Always pass the completion to the _LAST_ of the async database // view registrations. [TSDatabaseView asyncRegisterLazyRestoreAttachmentsDatabaseView:storage completion:completion]; diff --git a/SignalServiceKit/src/Storage/OWSStorage.m b/SignalServiceKit/src/Storage/OWSStorage.m index 2145d78b9..8a61f5b2c 100644 --- a/SignalServiceKit/src/Storage/OWSStorage.m +++ b/SignalServiceKit/src/Storage/OWSStorage.m @@ -18,6 +18,8 @@ #import #import #import +#import +#import #import #import @@ -536,6 +538,15 @@ NSString *const kNSUserDefaults_DatabaseExtensionVersionMap = @"kNSUserDefaults_ extensionName:extensionName] options:secondaryIndex->options]; return secondaryIndexCopy; + } else if ([extension isKindOfClass:[YapDatabaseFullTextSearch class]]) { + YapDatabaseFullTextSearch *fullTextSearch = (YapDatabaseFullTextSearch *)extension; + + NSString *versionTag = [self appendSuffixToDatabaseExtensionVersionIfNecessary:fullTextSearch.versionTag extensionName:extensionName]; + YapDatabaseFullTextSearch *fullTextSearchCopy = [[YapDatabaseFullTextSearch alloc] initWithColumnNames:fullTextSearch->columnNames.array + handler:fullTextSearch->handler + versionTag:versionTag]; + + return fullTextSearchCopy; } else if ([extension isKindOfClass:[YapDatabaseCrossProcessNotification class]]) { // versionTag doesn't matter for YapDatabaseCrossProcessNotification. return extension;