Merge branch 'mkirk/fts-search-results-controller'

pull/1/head
Michael Kirk 7 years ago
commit f118066b3f

@ -228,7 +228,7 @@ SPEC CHECKSUMS:
PureLayout: 4d550abe49a94f24c2808b9b95db9131685fe4cd
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SignalServiceKit: 5cc6e8e249f381c5eaee8693c0dff20fc1a3eee0
SignalServiceKit: 3a65a39b6671c290e6258db78002527e085842ad
SocketRocket: dbb1554b8fc288ef8ef370d6285aeca7361be31e
SQLCipher: f9fcf29b2e59ced7defc2a2bdd0ebe79b40d4990
SSZipArchive: d4009d2ce5520a421f231fd97028cc0e2667eed8

@ -1 +1 @@
Subproject commit 4fa9dbed3419fc81f5afbf17aa1e35d62656c72e
Subproject commit 47c8a2611481201e17387b4335421f373f8c8e9b

@ -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 = "<group>"; };
45FBC5D01DF8592E00E9B410 /* SignalCall.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalCall.swift; sourceTree = "<group>"; };
45FDA43420A4D22700396358 /* OWSNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSNavigationBar.swift; sourceTree = "<group>"; };
4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearchViewController.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>";
@ -1911,7 +1914,6 @@
4541B719209D2D860008608F /* ViewModels */ = {
isa = PBXGroup;
children = (
4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -1922,6 +1924,7 @@
4541B71A209D2DAE0008608F /* ContactShareViewModel.swift */,
459B7759207BA3A80071D0AB /* OWSQuotedReplyModel.h */,
459B775A207BA3A80071D0AB /* OWSQuotedReplyModel.m */,
4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
@ -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 */,

@ -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 <b> 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)")
}
}
}

@ -38,7 +38,7 @@ typedef NS_ENUM(NSInteger, HomeViewMode) {
NSString *const kArchivedConversationsReuseIdentifier = @"kArchivedConversationsReuseIdentifier";
@interface HomeViewController () <UITableViewDelegate, UITableViewDataSource, UIViewControllerPreviewingDelegate>
@interface HomeViewController () <UITableViewDelegate, UITableViewDataSource, UIViewControllerPreviewingDelegate, UISearchResultsUpdating>
@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

@ -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("")
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)
}
}

@ -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"))
}
}

@ -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.";

@ -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)
}
}

@ -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

@ -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<TSGroupThread> = Searcher { (groupThread: TSGroupThread) in
let groupName = groupThread.groupModel.groupName
let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in

@ -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

@ -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<T> {
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<ObjCBool>)
ext.enumerateKeysAndObjects(matching: prefixQuery, with: nil) { (snippet: String, _: String, _: String, object: Any, stop: UnsafeMutablePointer<ObjCBool>) 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<TSGroupThread> = 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<TSContactThread> = SearchIndexer { (contactThread: TSContactThread) in
let recipientId = contactThread.contactIdentifier()
let searchableContent = recipientIndexer.index(recipientId)
return normalize(text: searchableContent)
}
private static let recipientIndexer: SearchIndexer<String> = SearchIndexer { (recipientId: String) in
let displayName = contactsManager.displayName(forPhoneIdentifier: recipientId)
let searchableContent = "\(recipientId) \(displayName)"
return normalize(text: searchableContent)
}
private static let messageIndexer: SearchIndexer<TSMessage> = 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)
}
}

@ -16,6 +16,7 @@
#import "OWSStorage+Subclass.h"
#import "TSDatabaseSecondaryIndexes.h"
#import "TSDatabaseView.h"
#import <SignalServiceKit/SignalServiceKit-Swift.h>
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];

@ -18,6 +18,8 @@
#import <YapDatabase/YapDatabaseAutoView.h>
#import <YapDatabase/YapDatabaseCrossProcessNotification.h>
#import <YapDatabase/YapDatabaseCryptoUtils.h>
#import <YapDatabase/YapDatabaseFullTextSearch.h>
#import <YapDatabase/YapDatabaseFullTextSearchPrivate.h>
#import <YapDatabase/YapDatabaseSecondaryIndex.h>
#import <YapDatabase/YapDatabaseSecondaryIndexPrivate.h>
@ -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;

Loading…
Cancel
Save