TM-SGNL-iOS/SignalServiceKit/Storage/SearchableNameIndexer.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

392 lines
15 KiB
Swift

//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import GRDB
public import LibSignalClient
public protocol SearchableNameIndexer {
/// Searches for `searchText` in the FTS index.
func search(
for searchText: String,
maxResults: Int,
tx: DBReadTransaction,
block: (_ indexableName: any IndexableName) throws -> Void
) rethrows
/// Inserts `indexableName` into the FTS index.
func insert(_ indexableName: IndexableName, tx: DBWriteTransaction)
/// Updates `indexableName` in the FTS index.
///
/// This will remove `indexableName` if it has no indexable content.
func update(_ indexableName: IndexableName, tx: DBWriteTransaction)
/// Removes `indexableName` from the FTS index.
func delete(_ indexableName: IndexableName, tx: DBWriteTransaction)
/// Inserts every SearchableName into the index.
///
/// The index must be empty when this method is called.
func indexEverything(tx: DBWriteTransaction)
/// Inserts every TSThread into the index.
///
/// The index must be empty of threads when this method is called.
func indexThreads(tx: DBWriteTransaction)
}
public class SearchableNameIndexerImpl: SearchableNameIndexer {
private let threadStore: any ThreadStore
private let signalAccountStore: any SignalAccountStore
private let userProfileStore: any UserProfileStore
private let signalRecipientStore: any RecipientDatabaseTable
private let usernameLookupRecordStore: any UsernameLookupRecordStore
private let nicknameRecordStore: any NicknameRecordStore
private let dbForReadTx: (DBReadTransaction) -> Database
private let dbForWriteTx: (DBWriteTransaction) -> Database
public enum Constants {
public static let databaseTableName = "SearchableName"
public static let databaseTableNameFTS = "SearchableNameFTS"
}
public init(
threadStore: any ThreadStore,
signalAccountStore: any SignalAccountStore,
userProfileStore: any UserProfileStore,
signalRecipientStore: any RecipientDatabaseTable,
usernameLookupRecordStore: any UsernameLookupRecordStore,
nicknameRecordStore: any NicknameRecordStore,
dbForReadTx: @escaping (DBReadTransaction) -> Database,
dbForWriteTx: @escaping (DBWriteTransaction) -> Database
) {
self.threadStore = threadStore
self.signalAccountStore = signalAccountStore
self.userProfileStore = userProfileStore
self.signalRecipientStore = signalRecipientStore
self.usernameLookupRecordStore = usernameLookupRecordStore
self.nicknameRecordStore = nicknameRecordStore
self.dbForReadTx = dbForReadTx
self.dbForWriteTx = dbForWriteTx
}
// MARK: - Search
public func search(
for searchText: String,
maxResults: Int,
tx: DBReadTransaction,
block: (_ indexableName: IndexableName) throws -> Void
) rethrows {
let query = FullTextSearchIndexer.buildQuery(for: searchText)
if query.isEmpty {
return
}
let cursor: RowCursor
do {
cursor = try Row.fetchCursor(
dbForReadTx(tx),
sql: """
SELECT
"\(Constants.databaseTableName)"."\(IdentifierColumnName.threadId.rawValue)",
"\(Constants.databaseTableName)"."\(IdentifierColumnName.signalAccountId.rawValue)",
"\(Constants.databaseTableName)"."\(IdentifierColumnName.userProfileId.rawValue)",
"\(Constants.databaseTableName)"."\(IdentifierColumnName.signalRecipientId.rawValue)",
"\(Constants.databaseTableName)"."\(IdentifierColumnName.usernameLookupRecordId.rawValue)",
"\(Constants.databaseTableName)"."\(IdentifierColumnName.nicknameRecordRecipientId.rawValue)"
FROM "\(Constants.databaseTableNameFTS)"
LEFT JOIN "\(Constants.databaseTableName)"
ON "\(Constants.databaseTableName)".rowId = "\(Constants.databaseTableNameFTS)".rowId
WHERE "\(Constants.databaseTableNameFTS)"."value" MATCH ?
ORDER BY rank
LIMIT \(maxResults)
""",
arguments: [query]
)
} catch {
Logger.warn("Couldn't search for names: \(error.grdbErrorForLogging)")
return
}
while true {
let identifier: IndexableNameIdentifier
do {
guard let row = try cursor.next() else {
break
}
if let threadId = (row[0] as Int64?) {
identifier = .tsThread(threadId)
} else if let signalAccountId = (row[1] as Int64?) {
identifier = .signalAccount(signalAccountId)
} else if let userProfileId = (row[2] as Int64?) {
identifier = .userProfile(userProfileId)
} else if let signalRecipientId = (row[3] as Int64?) {
identifier = .signalRecipient(signalRecipientId)
} else if let usernameLookupRecordId = (row[4] as Data?).flatMap({ try? Aci.parseFrom(serviceIdBinary: $0) }) {
identifier = .usernameLookupRecord(usernameLookupRecordId)
} else if let nicknameRecordRecipientId = (row[5] as Int64?) {
identifier = .nicknameRecord(recipientRowId: nicknameRecordRecipientId)
} else {
owsFailDebug("Couldn't find identifier for SearchableName")
continue
}
} catch {
Logger.warn("Couldn't search for names: \(error.grdbErrorForLogging)")
return
}
guard let indexableName = fetchIndexableName(for: identifier, tx: tx) else {
owsFailDebug("Couldn't find IndexableName for SearchableName")
continue
}
try block(indexableName)
}
}
private func fetchIndexableName(for identifier: IndexableNameIdentifier, tx: DBReadTransaction) -> (any IndexableName)? {
switch identifier {
case .tsThread(let value):
return threadStore.fetchThread(rowId: value, tx: tx)
case .signalAccount(let value):
return signalAccountStore.fetchSignalAccount(for: value, tx: tx)
case .userProfile(let value):
return userProfileStore.fetchUserProfile(for: value, tx: tx)
case .signalRecipient(let value):
return signalRecipientStore.fetchRecipient(rowId: value, tx: tx)
case .usernameLookupRecord(let value):
return usernameLookupRecordStore.fetchOne(forAci: value, tx: tx)
case .nicknameRecord(recipientRowId: let value):
return nicknameRecordStore.fetch(recipientRowID: value, tx: tx)
}
}
// MARK: - Indexing
public func insert(_ indexableName: IndexableName, tx: DBWriteTransaction) {
guard let value = indexableName.indexableNameContent() else {
return
}
let normalizedValue = FullTextSearchIndexer.normalizeText(value)
do {
let (identifierColumn, identifierValue) = indexableName.indexableNameIdentifier().columnNameAndValue()
try dbForWriteTx(tx).execute(
sql: """
INSERT INTO "\(Constants.databaseTableName)" ("\(identifierColumn.rawValue)", "value") VALUES (?, ?)
""",
arguments: [identifierValue, normalizedValue]
)
} catch {
Logger.warn("Couldn't insert object: \(error.grdbErrorForLogging)")
}
}
public func update(_ indexableName: IndexableName, tx: DBWriteTransaction) {
delete(indexableName, tx: tx)
insert(indexableName, tx: tx)
}
public func delete(_ indexableName: IndexableName, tx: DBWriteTransaction) {
do {
let (identifierColumn, identifierValue) = indexableName.indexableNameIdentifier().columnNameAndValue()
try dbForWriteTx(tx).execute(
sql: """
DELETE FROM "\(Constants.databaseTableName)" WHERE "\(identifierColumn.rawValue)"=?
""",
arguments: [identifierValue]
)
} catch {
Logger.warn("Couldn't delete object: \(error.grdbErrorForLogging)")
}
}
public func indexEverything(tx: DBWriteTransaction) {
indexThreads(tx: tx)
SignalAccount.anyEnumerate(transaction: SDSDB.shimOnlyBridge(tx)) { signalAccount, _ in
insert(signalAccount, tx: tx)
}
OWSUserProfile.anyEnumerate(transaction: SDSDB.shimOnlyBridge(tx)) { userProfile, _ in
insert(userProfile, tx: tx)
}
SignalRecipient.anyEnumerate(transaction: SDSDB.shimOnlyBridge(tx)) { signalRecipient, _ in
insert(signalRecipient, tx: tx)
}
usernameLookupRecordStore.enumerateAll(tx: tx) { usernameLookupRecord in
insert(usernameLookupRecord, tx: tx)
}
nicknameRecordStore.enumerateAll(tx: tx) { nicknameRecord in
insert(nicknameRecord, tx: tx)
}
}
public func indexThreads(tx: DBWriteTransaction) {
TSThread.anyEnumerate(transaction: SDSDB.shimOnlyBridge(tx)) { thread, _ in
insert(thread, tx: tx)
}
}
}
// MARK: - IdentifierColumnName
private enum IdentifierColumnName: String {
case threadId
case signalAccountId
case userProfileId
case signalRecipientId
case usernameLookupRecordId
case nicknameRecordRecipientId
}
// MARK: - IndexableNames
public enum IndexableNameIdentifier {
case tsThread(Int64)
case signalAccount(Int64)
case userProfile(Int64)
case signalRecipient(Int64)
case usernameLookupRecord(Aci)
case nicknameRecord(recipientRowId: Int64)
fileprivate func columnNameAndValue() -> (IdentifierColumnName, DatabaseValue) {
switch self {
case .tsThread(let value):
return (.threadId, value.databaseValue)
case .signalAccount(let value):
return (.signalAccountId, value.databaseValue)
case .userProfile(let value):
return (.userProfileId, value.databaseValue)
case .signalRecipient(let value):
return (.signalRecipientId, value.databaseValue)
case .usernameLookupRecord(let value):
return (.usernameLookupRecordId, Data(value.serviceIdBinary).databaseValue)
case .nicknameRecord(recipientRowId: let value):
return (.nicknameRecordRecipientId, value.databaseValue)
}
}
}
public protocol IndexableName {
func indexableNameIdentifier() -> IndexableNameIdentifier
func indexableNameContent() -> String?
}
extension TSThread: IndexableName {
public func indexableNameIdentifier() -> IndexableNameIdentifier {
return .tsThread(grdbId!.int64Value)
}
public func indexableNameContent() -> String? {
switch self {
case let groupThread as TSGroupThread:
return groupThread.groupModel.groupNameOrDefault
case let storyThread as TSPrivateStoryThread:
// This will return "My Story" for that thread.
return storyThread.name
default:
return nil
}
}
}
extension SignalAccount: IndexableName {
public func indexableNameIdentifier() -> IndexableNameIdentifier {
return .signalAccount(grdbId!.int64Value)
}
public func indexableNameContent() -> String? {
guard let nameComponents = contactNameComponents() else {
return nil
}
let systemContactName = DisplayName.SystemContactName(
nameComponents: nameComponents,
multipleAccountLabel: nil
)
let fullName = systemContactName.resolvedValue(config: DisplayName.Config(shouldUseSystemContactNicknames: false))
let nickname = systemContactName.resolvedValue(config: DisplayName.Config(shouldUseSystemContactNicknames: true))
return [fullName, nickname]
.removingDuplicates(uniquingElementsBy: { $0 })
.joined(separator: " ")
}
}
extension OWSUserProfile: IndexableName {
public func indexableNameIdentifier() -> IndexableNameIdentifier {
return .userProfile(grdbId!.int64Value)
}
public func indexableNameContent() -> String? {
if phoneNumber == Constants.localProfilePhoneNumber {
// We don't need to index the user profile for the local user.
return nil
}
guard let nameComponents else {
return nil
}
// No system contact here, so this value doesn't matter.
let config = DisplayName.Config(shouldUseSystemContactNicknames: false)
return DisplayName.profileName(nameComponents).resolvedValue(config: config)
}
}
extension SignalRecipient: IndexableName {
public func indexableNameIdentifier() -> IndexableNameIdentifier {
return .signalRecipient(grdbId!.int64Value)
}
public func indexableNameContent() -> String? {
guard let phoneNumber else {
return nil
}
let nationalNumber: String? = { (phoneNumber: String) -> String? in
guard phoneNumber != OWSUserProfile.Constants.localProfilePhoneNumber else {
return nil
}
guard let phoneNumberObj = SSKEnvironment.shared.phoneNumberUtilRef.parseE164(phoneNumber) else {
Logger.error("couldn't parse phoneNumber: \(phoneNumber)")
return nil
}
let nationalNumber = SSKEnvironment.shared.phoneNumberUtilRef.formattedNationalNumber(for: phoneNumberObj)
guard let digitScalars = nationalNumber?.unicodeScalars.filter({ CharacterSet.decimalDigits.contains($0) }) else {
Logger.error("couldn't parse phoneNumber: \(phoneNumber)")
return nil
}
return String(String.UnicodeScalarView(digitScalars))
}(phoneNumber.stringValue)
return [phoneNumber.stringValue, nationalNumber]
.compacted()
.removingDuplicates(uniquingElementsBy: { $0 })
.joined(separator: " ")
}
}
extension UsernameLookupRecord: IndexableName {
public func indexableNameIdentifier() -> IndexableNameIdentifier {
return .usernameLookupRecord(Aci(fromUUID: aci))
}
public func indexableNameContent() -> String? {
return username
}
}
extension NicknameRecord: IndexableName {
public func indexableNameIdentifier() -> IndexableNameIdentifier {
return .nicknameRecord(recipientRowId: self.recipientRowID)
}
public func indexableNameContent() -> String? {
guard let profileName = ProfileName(nicknameRecord: self) else { return nil }
// No system contact here, so this value doesn't matter.
let config = DisplayName.Config(shouldUseSystemContactNicknames: false)
return DisplayName.nickname(profileName).resolvedValue(config: config)
}
}