This PR is the first in a series that will optimize looking up full names of group members. The biggest source of slowness when opening a group chat is looking up the full names of group members in the search for duplicates. It is slow because it requires multiple db queries for each member. The characterstic feature of this algorithm is the iterative process of assigning names to signal addresses. For example, some contacts' names may be cached. For others, their profiles must be fetched. For those without profiles, their phone numbers must be formatted (which requires fetching SignalAccounts). For those without phone numbers, their user names must be formatted. This PR creates a class called Refinery. Its job is to make it easy to assign values to keys through multiple passes, where each pass may succeed only for a subset of keys. This is useful because we will eventually issue a single DB query for some of these passes.
294 lines
14 KiB
Swift
294 lines
14 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
/// A collection of addresses (and adjacent info) that collide (i.e. the user many confuse one element's `currentName` for another)
|
|
/// Useful when reporting a profile spoofing attempt to the user.
|
|
/// In cases where a colliding addresses' display name has recently changed, `oldName` and `latestUpdate` may be populated.
|
|
public struct NameCollision: Dependencies {
|
|
public struct Element {
|
|
public let address: SignalServiceAddress
|
|
public let currentName: String
|
|
public let oldName: String?
|
|
public let latestUpdateTimestamp: UInt64?
|
|
}
|
|
|
|
public let elements: [Element]
|
|
public init(_ elements: [Element]) {
|
|
self.elements = elements
|
|
}
|
|
}
|
|
|
|
public protocol NameCollisionFinder {
|
|
var thread: TSThread { get }
|
|
|
|
/// Finds collections of thread participants that have a colliding display name
|
|
func findCollisions(transaction: SDSAnyReadTransaction) -> [NameCollision]
|
|
|
|
/// Invoked whenever the user has opted to ignore any remaining collisions
|
|
func markCollisionsAsResolved(transaction: SDSAnyWriteTransaction)
|
|
}
|
|
|
|
/// Finds all name collisions for a given contact thread
|
|
/// Compares the contact thread recipient with all known
|
|
public class ContactThreadNameCollisionFinder: NameCollisionFinder, Dependencies {
|
|
var contactThread: TSContactThread
|
|
public var thread: TSThread { contactThread }
|
|
|
|
public init(thread: TSContactThread) {
|
|
contactThread = thread
|
|
}
|
|
|
|
public func findCollisions(transaction: SDSAnyReadTransaction) -> [NameCollision] {
|
|
// Only look for name collisions in pending message requests
|
|
guard let updatedThread = TSContactThread.getWithContactAddress(contactThread.contactAddress, transaction: transaction) else {
|
|
return []
|
|
}
|
|
contactThread = updatedThread
|
|
guard contactThread.hasPendingMessageRequest(transaction: transaction.unwrapGrdbRead) else { return [] }
|
|
|
|
let collisionCandidates = Self.contactsViewHelper.signalAccounts(
|
|
matchingSearch: contactThread.contactAddress.displayName(transaction: transaction),
|
|
transaction: transaction)
|
|
|
|
// ContactsViewHelper uses substring matching, so it might return false positives
|
|
// Filter to just the matches that are valid collisions
|
|
let collidingAddresses = collisionCandidates
|
|
.map { $0.recipientAddress }
|
|
.filter { !$0.isLocalAddress }
|
|
.filter { isCollision($0, contactThread.contactAddress, transaction: transaction) }
|
|
|
|
if collidingAddresses.isEmpty {
|
|
return []
|
|
} else {
|
|
let collidingAddresses = [contactThread.contactAddress] + collidingAddresses
|
|
let collision = NameCollision(collidingAddresses.map {
|
|
NameCollision.Element(
|
|
address: $0,
|
|
currentName: $0.displayName(transaction: transaction),
|
|
oldName: nil,
|
|
latestUpdateTimestamp: nil)
|
|
})
|
|
|
|
// Contact threads can only have one collision
|
|
return [collision]
|
|
}
|
|
}
|
|
|
|
public func markCollisionsAsResolved(transaction: SDSAnyWriteTransaction) {
|
|
// Do nothing
|
|
// Contact threads always display all collisions
|
|
}
|
|
|
|
private func isCollision(
|
|
_ address1: SignalServiceAddress,
|
|
_ address2: SignalServiceAddress,
|
|
transaction: SDSAnyReadTransaction) -> Bool {
|
|
|
|
guard address1 != address2 else { return false }
|
|
let name1 = address1.displayName(transaction: transaction)
|
|
let name2 = address2.displayName(transaction: transaction)
|
|
return name1 == name2
|
|
}
|
|
}
|
|
|
|
public class GroupMembershipNameCollisionFinder: NameCollisionFinder {
|
|
private var groupThread: TSGroupThread
|
|
public var thread: TSThread { groupThread }
|
|
|
|
/// Contains a list of recent profile update messages for the given address
|
|
/// "Recent" is defined as all profile update messages since a call to `markCollisionsAsResolved`
|
|
/// This is only fetched once for the lifetime of the collision finder. Thread-safe.
|
|
let lock = UnfairLock()
|
|
private var recentProfileUpdateMessages: [SignalServiceAddress: [TSInfoMessage]]?
|
|
public var hasFetchedProfileUpdateMessages: Bool {
|
|
lock.withLock { recentProfileUpdateMessages != nil }
|
|
}
|
|
|
|
public init(thread: TSGroupThread) {
|
|
groupThread = thread
|
|
}
|
|
|
|
public func findCollisions(transaction: SDSAnyReadTransaction) -> [NameCollision] {
|
|
guard let updatedThread = TSGroupThread.anyFetchGroupThread(uniqueId: groupThread.uniqueId, transaction: transaction) else {
|
|
return []
|
|
}
|
|
groupThread = updatedThread
|
|
|
|
// Build a dictionary mapping displayName -> (All addresses with that name)
|
|
let groupMembers = groupThread.groupModel.groupMembers
|
|
let displayNames = SignalServiceAddressCache.contactsManager.displayNames(forAddresses: groupMembers,
|
|
transaction: transaction)
|
|
var collisionMap = [String: [SignalServiceAddress]]()
|
|
for (address, name) in zip(groupMembers, displayNames) {
|
|
collisionMap[name, default: []].append(address)
|
|
}
|
|
let allAddressCollisions = Array(collisionMap.values.filter { $0.count >= 2 })
|
|
|
|
// Early-exit to avoid fetching unnecessary profile update messages
|
|
// Move our start search pointer to try and proactively reduce our future interaction search space
|
|
guard !allAddressCollisions.isEmpty else {
|
|
setRecentProfileUpdateSearchStartIdToMax(transaction: transaction)
|
|
return []
|
|
}
|
|
|
|
let profileUpdates = fetchRecentProfileUpdates(transaction: transaction)
|
|
|
|
// For each collision set:
|
|
// - Filter out any collision set that doesn't have at least one address that was recently changed
|
|
// - Map the remaining address arrays to `NameCollision`s
|
|
// - Build each NameCollision entry by grabbing:
|
|
// - The address
|
|
// - The oldest profile name in our update window (if available)
|
|
// - The most recent update message (if available)
|
|
//
|
|
// We use the oldest/newest message in this way, because we're reporting to the user what has changed
|
|
// e.g. if I change my profile name from "Michelle1" to "Michelle2" to "Michelle3"
|
|
// the user will want to see it condensed to "Michelle3 changed their name from Michelle1 to Michelle3"
|
|
// or something similar. We'll want to sort by most recent profile change, so we grab the newest update
|
|
// message's timestamp as well.
|
|
|
|
let filteredCollisions = allAddressCollisions
|
|
.filter { $0.contains(where: { address in profileUpdates[address] != nil }) }
|
|
.map { collidingAddresses in
|
|
NameCollision(collidingAddresses.map { address in
|
|
let profileUpdateMessages = profileUpdates[address]
|
|
let oldestUpdateMessage = profileUpdateMessages?.min(by: { $0.sortId < $1.sortId })
|
|
let newestUpdateMessage = profileUpdateMessages?.max(by: { $0.sortId < $1.sortId })
|
|
|
|
return NameCollision.Element(
|
|
address: address,
|
|
currentName: address.displayName(transaction: transaction),
|
|
oldName: oldestUpdateMessage?.profileChangesOldFullName,
|
|
latestUpdateTimestamp: newestUpdateMessage?.timestamp)
|
|
})
|
|
}
|
|
|
|
// Neat! No collisions. Let's make sure we update our search space since we know there are no collisions
|
|
// in the update interactions we've fetched
|
|
if filteredCollisions.isEmpty {
|
|
SDSDatabaseStorage.shared.asyncWrite { writeTx in
|
|
self.markCollisionsAsResolved(transaction: writeTx)
|
|
}
|
|
}
|
|
|
|
return filteredCollisions
|
|
}
|
|
|
|
private func fetchRecentProfileUpdates(transaction: SDSAnyReadTransaction) -> [SignalServiceAddress: [TSInfoMessage]] {
|
|
lock.withLock {
|
|
if let cachedResults = recentProfileUpdateMessages { return cachedResults }
|
|
|
|
let sortId = recentProfileUpdateSearchStartId(transaction: transaction) ?? 0
|
|
let finder = GRDBInteractionFinder(threadUniqueId: thread.uniqueId)
|
|
|
|
// Build a map from (SignalServiceAddress) -> (List of recent profile update messages)
|
|
let results: [SignalServiceAddress: [TSInfoMessage]] = finder
|
|
.profileUpdateInteractions(afterSortId: sortId, transaction: transaction.unwrapGrdbRead)
|
|
.reduce(into: [:]) { dictBuilder, message in
|
|
guard let address = message.profileChangeAddress else { return }
|
|
dictBuilder[address, default: []].append(message)
|
|
}
|
|
|
|
recentProfileUpdateMessages = results
|
|
return results
|
|
}
|
|
}
|
|
|
|
public func markCollisionsAsResolved(transaction: SDSAnyWriteTransaction) {
|
|
lock.withLock {
|
|
let allRecentMessages = recentProfileUpdateMessages?.values.flatMap({ $0 })
|
|
guard let newMaxSortId = allRecentMessages?.max(by: { $0.sortId < $1.sortId })?.sortId else { return }
|
|
|
|
setRecentProfileUpdateSearchStartId(newValue: newMaxSortId, transaction: transaction)
|
|
recentProfileUpdateMessages?.removeAll()
|
|
}
|
|
}
|
|
|
|
// MARK: Storage
|
|
|
|
private static var keyValueStore = SDSKeyValueStore(collection: "GroupThreadCollisionFinder")
|
|
|
|
private func recentProfileUpdateSearchStartId(transaction: SDSAnyReadTransaction) -> UInt64? {
|
|
Self.keyValueStore.getUInt64(groupThread.uniqueId, transaction: transaction)
|
|
}
|
|
|
|
private func setRecentProfileUpdateSearchStartId(newValue: UInt64, transaction: SDSAnyWriteTransaction) {
|
|
let existingValue = recentProfileUpdateSearchStartId(transaction: transaction) ?? 0
|
|
Self.keyValueStore.setUInt64(max(newValue, existingValue), key: groupThread.uniqueId, transaction: transaction)
|
|
}
|
|
|
|
private func setRecentProfileUpdateSearchStartIdToMax(transaction: SDSAnyReadTransaction) {
|
|
// This is a perf optimization to proactively reduce our search space, so it doesn't need to be exact.
|
|
// `lastInteractionRowId` is the current latest interaction on the thread when we checked for collisions.
|
|
//
|
|
// - If that interaction is deleted in the future, that's okay since rowIDs are monotonic. Any future unchecked
|
|
// interactions will always have a rowID after the current value.
|
|
// - This can be called from the main thread, so we perform the write async to avoid blocking. If the write
|
|
// doesn't make it in time, that's okay. In the worst case, we'll just recheck interactions that we already
|
|
// know don't contain profile changes. As long as this is successful a majority of the time, we'll keep a lid
|
|
// on our search space.
|
|
// - In our searchStartId cache, we always set the max(currentValue, newValue). So even if maxInteractionId
|
|
// is unset or invalid, we'll never mistakenly grow our search space by setting the startId to a smaller value.
|
|
let maxInteractionId = UInt64(thread.lastInteractionRowId)
|
|
SDSDatabaseStorage.shared.asyncWrite { writeTx in
|
|
self.setRecentProfileUpdateSearchStartId(newValue: maxInteractionId, transaction: writeTx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
fileprivate extension SignalServiceAddress {
|
|
func displayName(transaction readTx: SDSAnyReadTransaction) -> String {
|
|
Self.contactsManager.displayName(for: self, transaction: readTx)
|
|
}
|
|
}
|
|
|
|
public extension NameCollision {
|
|
func standardSort(readTx: SDSAnyReadTransaction) -> NameCollision {
|
|
NameCollision(
|
|
elements.sorted { element1, element2 in
|
|
// Given two colliding elements, we sort by:
|
|
// - Most recent profile update first (if available)
|
|
// - SignalServiceAddress UUID otherwise, to ensure stable sorting
|
|
if element1.latestUpdateTimestamp != nil || element2.latestUpdateTimestamp != nil {
|
|
return (element1.latestUpdateTimestamp ?? 0) > (element2.latestUpdateTimestamp ?? 0)
|
|
|
|
} else {
|
|
return element1.address.sortKey < element2.address.sortKey
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
public extension Array where Element == NameCollision {
|
|
func standardSort(readTx: SDSAnyReadTransaction) -> [NameCollision] {
|
|
self
|
|
.map { $0.standardSort(readTx: readTx) }
|
|
.sorted { set1, set2 in
|
|
// Across collision sets, (e.g. two independent collisions, two people named Michelle, two people named Nora)
|
|
// We'll sort by the smallest comparable name in each collision set
|
|
// (Usually they're all the same, but this might change in the future when comparing homographs)
|
|
// This is to try and maintain stable sorting as individual elements within the set are resolved
|
|
|
|
let smallestName1 = set1.elements
|
|
.map { SSKEnvironment.shared.contactsManager.comparableName(for: $0.address, transaction: readTx )}
|
|
.min()
|
|
let smallestName2 = set2.elements
|
|
.map { SSKEnvironment.shared.contactsManager.comparableName(for: $0.address, transaction: readTx )}
|
|
.min()
|
|
|
|
switch (smallestName1, smallestName2) {
|
|
case let (name1?, name2?):
|
|
return name1 < name2
|
|
case (_?, nil):
|
|
return true
|
|
case (nil, _):
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|