Signal-iOS/Signal/test/util/GroupAndContactStreamTest.swift
George Nachman 36119e4367 Introduce Refinery to help batch DB queries.
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.
2022-03-04 10:44:07 -08:00

429 lines
18 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import XCTest
import Contacts
class GroupAndContactStreamTest: SignalBaseTest {
// MARK: - Test Life Cycle
override func setUp() {
super.setUp()
// ensure local client has necessary "registered" state
let localE164Identifier = "+13235551234"
let localUUID = UUID(uuidString: "83DF967A-5CBD-43EB-AE9A-571A930F78D6")!
tsAccountManager.registerForTests(withLocalNumber: localE164Identifier, uuid: localUUID)
}
// MARK: -
let outputContactSyncData = "GwoMKzEzMjMxMTExMTExEgdBbGljZS0xQABYADMSB0FsaWNlLTJAAEokMzFDRTE0MTItOUEyOC00RTZGLUI0RUUtMjIyMjIyMjIyMjIyWABBCgwrMTMyMTMzMzMzMzMSB0FsaWNlLTNAAEokMUQ0QUIwNDUtODhGQi00QzRFLTlGNkEtMzMzMzMzMzMzMzMzWAA="
func test_writeContactSync() throws {
let signalAccounts = [
SignalAccount(address: .init(phoneNumber: "+13231111111")),
SignalAccount(address: .init(uuidString: "31ce1412-9a28-4e6f-b4ee-222222222222")),
SignalAccount(address: .init(uuidString: "1d4ab045-88fb-4c4e-9f6a-333333333333", phoneNumber: "+13213333333"))
]
let streamData = try buildContactSyncData(signalAccounts: signalAccounts)
XCTAssertEqual(streamData.base64EncodedString(), outputContactSyncData)
}
func test_readContactSync() throws {
var contacts: [ContactDetails] = []
let data = Data(base64Encoded: outputContactSyncData)!
try data.withUnsafeBytes { bufferPtr in
if let baseAddress = bufferPtr.baseAddress, bufferPtr.count > 0 {
let pointer = baseAddress.assumingMemoryBound(to: UInt8.self)
let inputStream = ChunkedInputStream(forReadingFrom: pointer, count: bufferPtr.count)
let contactStream = ContactsInputStream(inputStream: inputStream)
while let nextContact = try contactStream.decodeContact() {
contacts.append(nextContact)
}
}
}
guard contacts.count == 3 else {
XCTFail("unexpected contact count: \(contacts.count)")
return
}
do {
let contact = contacts[0]
XCTAssertEqual("+13231111111", contact.address.phoneNumber)
XCTAssertNil(contact.address.uuid)
XCTAssertEqual("Alice-1", contact.name)
XCTAssertNil(contact.verifiedProto)
XCTAssertNil(contact.profileKey)
XCTAssertEqual(false, contact.isBlocked)
XCTAssertEqual(0, contact.expireTimer)
XCTAssertEqual(false, contact.isArchived)
XCTAssertNil(contact.inboxSortOrder)
}
do {
let contact = contacts[1]
XCTAssertNil(contact.address.phoneNumber)
XCTAssertEqual("31CE1412-9A28-4E6F-B4EE-222222222222", contact.address.uuid?.uuidString)
XCTAssertEqual("Alice-2", contact.name)
XCTAssertNil(contact.verifiedProto)
XCTAssertNil(contact.profileKey)
XCTAssertEqual(false, contact.isBlocked)
XCTAssertEqual(0, contact.expireTimer)
XCTAssertEqual(false, contact.isArchived)
XCTAssertNil(contact.inboxSortOrder)
}
do {
let contact = contacts[2]
XCTAssertEqual("+13213333333", contact.address.phoneNumber)
XCTAssertEqual("1D4AB045-88FB-4C4E-9F6A-333333333333", contact.address.uuid?.uuidString)
XCTAssertEqual("Alice-3", contact.name)
XCTAssertNil(contact.verifiedProto)
XCTAssertNil(contact.profileKey)
XCTAssertEqual(false, contact.isBlocked)
XCTAssertEqual(0, contact.expireTimer)
XCTAssertEqual(false, contact.isArchived)
XCTAssertNil(contact.inboxSortOrder)
}
}
let outputGroupSyncData = "ZAoQc111Fz2xlVb3YbtcfwN0SBoMKzEzMjEzMjE0MzIxGgwrMTMyMTMyMTQzMjMiDgoJaW1hZ2UvcG5nEKMBMABKDhIMKzEzMjEzMjE0MzIxSg4SDCsxMzIxMzIxNDMyM1ABWACJUE5HDQoaCgAAAA1JSERSAAAAAQAAAAEIBgAAAB8VxIkAAAABc1JHQgCuzhzpAAAARGVYSWZNTQAqAAAACAABh2kABAAAAAEAAAAaAAAAAAADoAEAAwAAAAEAAQAAoAIABAAAAAEAAAABoAMABAAAAAEAAAABAAAAAPkinf4AAAANSURBVAgdY/jPwPAfAAUAAf/ID2IWAAAAAElFTkSuQmCCbwoQc222Fz2xlVb3YbtcfwN0SBIJQm9vayBDbHViGgwrMTMyMTMyMTQzMjEaDCsxNTU1MzIxNDMyMyIOCglpbWFnZS9wbmcQowEwAEoOEgwrMTMyMTMyMTQzMjFKDhIMKzE1NTUzMjE0MzIzUAJYAYlQTkcNChoKAAAADUlIRFIAAAABAAAAAQgGAAAAHxXEiQAAAAFzUkdCAK7OHOkAAABEZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAAGgAwAEAAAAAQAAAAEAAAAA+SKd/gAAAA1JREFUCB1jYGD4/x8AAwIB/6fhVKUAAAAASUVORK5CYIKNAQoQc333Fz2xlVb3YbtcfwN0SBIJQ29vayBCbHViGgwrMTMyMTMyMTMzMzMaDCsxMzIzNTU1MTIzNBoMKzE1NTUzMjEyMjIyIg4KCWltYWdlL3BuZxCjATAASg4SDCsxMzIxMzIxMzMzM0oOEgwrMTMyMzU1NTEyMzRKDhIMKzE1NTUzMjEyMjIyUABYAYlQTkcNChoKAAAADUlIRFIAAAABAAAAAQgGAAAAHxXEiQAAAAFzUkdCAK7OHOkAAABEZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAAGgAwAEAAAAAQAAAAEAAAAA+SKd/gAAAA1JREFUCB1jYPjP8B8ABAEB/zB9GO4AAAAASUVORK5CYII="
let groupImageData1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAAaADAAQAAAABAAAAAQAAAAD5Ip3+AAAADUlEQVQIHWP4z8DwHwAFAAH/yA9iFgAAAABJRU5ErkJggg=="
let groupImageData2 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAAaADAAQAAAABAAAAAQAAAAD5Ip3+AAAADUlEQVQIHWNgYPj/HwADAgH/p+FUpQAAAABJRU5ErkJggg=="
let groupImageData3 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAAaADAAQAAAABAAAAAQAAAAD5Ip3+AAAADUlEQVQIHWNg+M/wHwAEAQH/MH0Y7gAAAABJRU5ErkJggg=="
func test_writeGroupSync() throws {
let groupAvatarData1 = Data(base64Encoded: groupImageData1)!
let groupAvatarData2 = Data(base64Encoded: groupImageData2)!
let groupAvatarData3 = Data(base64Encoded: groupImageData3)!
let group1: TSGroupThread = {
let groupId = Data(base64Encoded: "c111Fz2xlVb3YbtcfwN0SA==")!
let groupMembers: [SignalServiceAddress] = [
.init(phoneNumber: "+13213214321"),
.init(uuidString: "1d4ab045-88fb-4c4e-9f6a-f921124bd529", phoneNumber: "+13213214323")
]
var thread: TSGroupThread!
write { transaction in
thread = try! GroupManager.createGroupForTests(members: groupMembers,
avatarData: groupAvatarData1,
groupId: groupId,
transaction: transaction)
}
return thread
}()
let group2: TSGroupThread = {
let groupId = Data(base64Encoded: "c222Fz2xlVb3YbtcfwN0SA==")!
let groupMembers: [SignalServiceAddress] = [
.init(phoneNumber: "+13213214321"),
.init(uuidString: "55555555-88fb-4c4e-9f6a-f921124bd529", phoneNumber: "+15553214323")
]
var thread: TSGroupThread!
write {
thread = try! GroupManager.createGroupForTests(members: groupMembers,
name: "Book Club",
avatarData: groupAvatarData2,
groupId: groupId,
transaction: $0)
thread.shouldThreadBeVisible = true
thread.anyOverwritingUpdate(transaction: $0)
ThreadAssociatedData.fetchOrDefault(
for: thread,
transaction: $0
).updateWith(
isArchived: true,
updateStorageService: false,
transaction: $0
)
}
return thread
}()
let group3: TSGroupThread = {
let groupId = Data(base64Encoded: "c333Fz2xlVb3YbtcfwN0SA==")!
let groupMembers: [SignalServiceAddress] = [
.init(phoneNumber: "+13213213333"),
tsAccountManager.localAddress!,
.init(uuidString: "55555555-88fb-4c4e-9f6a-222222222222", phoneNumber: "+15553212222")
]
var thread: TSGroupThread!
write { transaction in
thread = try! GroupManager.createGroupForTests(members: groupMembers,
name: "Cook Blub",
avatarData: groupAvatarData3,
groupId: groupId,
transaction: transaction)
thread.shouldThreadBeVisible = true
thread.anyOverwritingUpdate(transaction: transaction)
let messageFactory = OutgoingMessageFactory()
messageFactory.threadCreator = { _ in thread }
_ = messageFactory.create(transaction: transaction)
ThreadAssociatedData.fetchOrDefault(
for: thread,
transaction: transaction
).updateWith(
isArchived: true,
updateStorageService: false,
transaction: transaction
)
}
return thread
}()
let streamData = try buildGroupSyncData(groupThreads: [group1, group2, group3])
XCTAssertEqual(streamData.base64EncodedString(), outputGroupSyncData)
}
func test_readGroupSync() throws {
let groupAvatarData1 = Data(base64Encoded: groupImageData1)!
let groupAvatarData2 = Data(base64Encoded: groupImageData2)!
let groupAvatarData3 = Data(base64Encoded: groupImageData3)!
var groups: [GroupDetails] = []
let data = Data(base64Encoded: outputGroupSyncData)!
try data.withUnsafeBytes { bufferPtr in
if let baseAddress = bufferPtr.baseAddress, bufferPtr.count > 0 {
let pointer = baseAddress.assumingMemoryBound(to: UInt8.self)
let inputStream = ChunkedInputStream(forReadingFrom: pointer, count: bufferPtr.count)
let groupStream = GroupsInputStream(inputStream: inputStream)
while let nextGroup = try groupStream.decodeGroup() {
groups.append(nextGroup)
}
}
}
guard groups.count == 3 else {
XCTFail("unexpected group count: \(groups.count)")
return
}
do {
let group = groups[0]
XCTAssertEqual(group.groupId, Data(base64Encoded: "c111Fz2xlVb3YbtcfwN0SA==")!)
XCTAssertEqual(group.name, nil)
XCTAssertEqual(group.memberAddresses, [
SignalServiceAddress(phoneNumber: "+13213214321"),
SignalServiceAddress(uuidString: "1d4ab045-88fb-4c4e-9f6a-f921124bd529", phoneNumber: "+13213214323")
])
XCTAssertEqual(group.isBlocked, false)
XCTAssertEqual(group.expireTimer, 0)
XCTAssertEqual(group.avatarData, groupAvatarData1)
XCTAssertEqual(false, group.isArchived)
XCTAssertEqual(1, group.inboxSortOrder)
}
do {
let group = groups[1]
XCTAssertEqual(group.groupId, Data(base64Encoded: "c222Fz2xlVb3YbtcfwN0SA==")!)
XCTAssertEqual(group.name, "Book Club")
XCTAssertEqual(group.memberAddresses, [
SignalServiceAddress(phoneNumber: "+13213214321"),
SignalServiceAddress(uuidString: "55555555-88fb-4c4e-9f6a-f921124bd529", phoneNumber: "+15553214323")
])
XCTAssertEqual(group.isBlocked, false)
XCTAssertEqual(group.expireTimer, 0)
XCTAssertEqual(group.avatarData, groupAvatarData2)
XCTAssertEqual(true, group.isArchived)
XCTAssertEqual(2, group.inboxSortOrder)
}
do {
let group = groups[2]
XCTAssertEqual(group.groupId, Data(base64Encoded: "c333Fz2xlVb3YbtcfwN0SA==")!)
XCTAssertEqual(group.name, "Cook Blub")
XCTAssertEqual(group.memberAddresses, [
SignalServiceAddress(phoneNumber: "+13213213333"),
tsAccountManager.localAddress!,
SignalServiceAddress(uuidString: "55555555-88FB-4C4E-9F6A-222222222222", phoneNumber: "+15553212222")
])
XCTAssertEqual(group.isBlocked, false)
XCTAssertEqual(group.expireTimer, 0)
XCTAssertEqual(group.avatarData, groupAvatarData3)
XCTAssertEqual(true, group.isArchived)
XCTAssertEqual(0, group.inboxSortOrder)
}
}
func buildContactSyncData(signalAccounts: [SignalAccount]) throws -> Data {
let contactsManager = TestContactsManager()
let dataOutputStream = OutputStream(toMemory: ())
dataOutputStream.open()
let contactsOutputStream = OWSContactsOutputStream(outputStream: dataOutputStream)
for signalAccount in signalAccounts {
let contactFactory = ContactFactory()
contactFactory.fullNameBuilder = {
"Alice-\(signalAccount.recipientAddress.serviceIdentifier!.suffix(1))"
}
contactFactory.cnContactIdBuilder = { "123" }
contactFactory.uniqueIdBuilder = { "123" }
signalAccount.replaceContactForTests(try contactFactory.build())
contactsOutputStream.write(signalAccount,
recipientIdentity: nil,
profileKeyData: nil,
contactsManager: contactsManager,
disappearingMessagesConfiguration: nil,
isArchived: false,
inboxPosition: nil)
}
dataOutputStream.close()
guard !contactsOutputStream.hasError else {
throw OWSAssertionError("contactsOutputStream.hasError")
}
return dataOutputStream.property(forKey: .dataWrittenToMemoryStreamKey) as! Data
}
func buildGroupSyncData(groupThreads: [TSGroupThread]) throws -> Data {
let dataOutputStream = OutputStream(toMemory: ())
dataOutputStream.open()
let groupsOutputStream = OWSGroupsOutputStream(outputStream: dataOutputStream)
read { transaction in
for groupThread in groupThreads {
groupsOutputStream.writeGroup(groupThread, transaction: transaction)
}
}
dataOutputStream.close()
guard !groupsOutputStream.hasError else {
throw OWSAssertionError("contactsOutputStream.hasError")
}
return dataOutputStream.property(forKey: .dataWrittenToMemoryStreamKey) as! Data
}
}
class TestContactsManager: NSObject, ContactsManagerProtocol {
func isSystemContactWithSignalAccount(_ address: SignalServiceAddress) -> Bool {
false
}
func isSystemContactWithSignalAccount(_ address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> Bool {
false
}
func hasNameInSystemContacts(for address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> Bool {
false
}
func comparableName(for address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String {
self.displayName(for: address)
}
func comparableName(for signalAccount: SignalAccount, transaction: SDSAnyReadTransaction) -> String {
signalAccount.recipientAddress.stringForDisplay
}
func displayName(for address: SignalServiceAddress) -> String {
address.stringForDisplay
}
func displayName(for address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String {
address.stringForDisplay
}
func displayName(for signalAccount: SignalAccount) -> String {
signalAccount.recipientAddress.stringForDisplay
}
func displayNames(forAddresses addresses: [SignalServiceAddress], transaction: SDSAnyReadTransaction) -> [String] {
return addresses.map {
$0.stringForDisplay
}
}
func displayName(for thread: TSThread, transaction: SDSAnyReadTransaction) -> String {
"Fake Name"
}
func displayNameWithSneakyTransaction(thread: TSThread) -> String {
"Fake Name"
}
func shortDisplayName(for address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String {
address.stringForDisplay
}
func nameComponents(for address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> PersonNameComponents? {
PersonNameComponents()
}
func signalAccounts() -> [SignalAccount] {
[]
}
func isSystemContactWithSneakyTransaction(phoneNumber: String) -> Bool {
return true
}
func isSystemContact(phoneNumber: String, transaction: SDSAnyReadTransaction) -> Bool {
return true
}
func isSystemContactWithSneakyTransaction(address: SignalServiceAddress) -> Bool {
return true
}
func isSystemContact(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> Bool {
return true
}
func isSystemContact(withSignalAccount phoneNumber: String) -> Bool {
true
}
func isSystemContact(withSignalAccount phoneNumber: String, transaction: SDSAnyReadTransaction) -> Bool {
true
}
func compare(signalAccount left: SignalAccount, with right: SignalAccount) -> ComparisonResult {
.orderedSame
}
public func sortSignalServiceAddresses(_ addresses: [SignalServiceAddress],
transaction: SDSAnyReadTransaction) -> [SignalServiceAddress] {
addresses
}
func cnContact(withId contactId: String?) -> CNContact? {
nil
}
func avatarData(forCNContactId contactId: String?) -> Data? {
nil
}
func avatarImage(forCNContactId contactId: String?) -> UIImage? {
nil
}
func leaseCacheSize(_ size: Int) -> ModelReadCacheSizeLease? {
return nil
}
var unknownUserLabel: String = "unknown"
}