Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Contacts/SignalServiceAddress.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
public import LibSignalClient

// MARK: -

@objc
public class SignalServiceAddress: NSObject, NSCopying, NSSecureCoding, Codable {
    private let cachedAddress: CachedAddress

    @objc
    public var phoneNumber: String? {
        cachedAddress.identifiers.get().phoneNumber
    }

    public var e164: E164? { phoneNumber.flatMap { E164($0) } }

    @objc
    public var e164ObjC: E164ObjC? { phoneNumber.flatMap { E164ObjC($0) } }

    /// The "service id" (could be an ACI or PNI).
    ///
    /// This value is optional since it may not be present in all cases. Some
    /// examples:
    ///
    /// * A really old recipient, who you may have message history with, may not
    /// have re-registered since UUIDs were added.
    ///
    /// * When doing "find by phone number", there will be a window of time
    /// where all we know about a recipient is their e164.
    public var serviceId: ServiceId? {
        cachedAddress.identifiers.get().serviceId
    }

    @objc
    public var serviceIdObjC: ServiceIdObjC? { serviceId.map { ServiceIdObjC.wrapValue($0) } }

    /// Returns the `serviceId` if it's an ACI.
    ///
    /// - Note: Call this only if you **expect** an `Aci` (or nil). If the
    /// result could be a `Pni`, you shouldn't call this method.
    public var aci: Aci? {
        guard let result = serviceId as? Aci? else {
            owsFailDebug("Expected an ACI but found something else.")
            return nil
        }
        return result
    }

    @objc
    public var aciString: String? { aci?.serviceIdString }

    @objc
    public var aciUppercaseString: String? { aci?.serviceIdUppercaseString }

    @objc
    public var serviceIdString: String? { serviceId?.serviceIdString }

    @objc
    public var serviceIdUppercaseString: String? { serviceId?.serviceIdUppercaseString }

    /// Returns a canonical address from the cache.
    ///
    /// If you initialize an address with the wrong phone number, that address
    /// will keep that phone number. This method will update that phone number
    /// to match what's currently in the cache (ie what's on SignalRecipient).
    public func withNormalizedPhoneNumber(cache: SignalServiceAddressCache? = nil) -> SignalServiceAddress {
        let identifiers = cachedAddress.identifiers.get()
        return SignalServiceAddress(
            serviceId: identifiers.serviceId,
            // If there's no ServiceId, then we look up the phone number in the cache.
            phoneNumber: (identifiers.serviceId == nil) ? identifiers.phoneNumber : nil,
            cache: cache ?? SSKEnvironment.shared.signalServiceAddressCacheRef,
        )
    }

    /// Returns a source-of-truth canonicalized address.
    ///
    /// If an address is initialized with the wrong phone number, it'll keep it
    /// until the phone number for the ACI changes; this method will update it
    /// immediately. If you initialize an address with a PNI, it'll keep the PNI
    /// forever; this method will update it to the ACI (but only if the ACI,
    /// PNI, and phone number are all known and linked to one another).
    public func withNormalizedPhoneNumberAndServiceId(cache: SignalServiceAddressCache? = nil) -> SignalServiceAddress {
        return withNormalizedPhoneNumber(cache: cache).withNormalizedServiceId(cache: cache)
    }

    private func withNormalizedServiceId(cache: SignalServiceAddressCache?) -> SignalServiceAddress {
        let identifiers = cachedAddress.identifiers.get()
        guard let phoneNumber = identifiers.phoneNumber, identifiers.serviceId is Pni else {
            // This is a private method, and `self` is already built against `cache`.
            return self
        }
        return SignalServiceAddress(
            serviceId: nil,
            phoneNumber: phoneNumber,
            cache: cache ?? SSKEnvironment.shared.signalServiceAddressCacheRef,
        )
    }

    // MARK: - Initializers

    /// Initializes a "legacy" address.
    ///
    /// Legacy addresses were saved by prior versions of the application before
    /// ACIs were known, so they generally contain only a phone number. They are
    /// sent through a migration path that allows them to be resolved to ACIs in
    /// cases where other SignalServiceAddresses can't be resolved to ACIs.
    ///
    /// "Modern legacy addresses" (ie modern builds writing to places that may
    /// also contain legacy addresses) will encode the ACI instead of the phone
    /// number, thus skipping the migration path in this initializer (good!).
    public convenience init(
        serviceId: ServiceId?,
        legacyPhoneNumber phoneNumber: String?,
        cache: SignalServiceAddressCache,
    ) {
        let normalizedAddress = NormalizedDatabaseRecordAddress(
            serviceId: serviceId,
            phoneNumber: phoneNumber,
        )
        self.init(
            serviceId: normalizedAddress?.serviceId,
            phoneNumber: normalizedAddress?.phoneNumber,
            isLegacyPhoneNumber: true,
            cache: cache,
        )
    }

    public static func legacyAddress(serviceId: ServiceId?, phoneNumber: String?) -> SignalServiceAddress {
        return SignalServiceAddress(
            serviceId: serviceId,
            legacyPhoneNumber: phoneNumber,
            cache: SSKEnvironment.shared.signalServiceAddressCacheRef,
        )
    }

    public static func legacyAddress(aciString: String?, phoneNumber: String?) -> SignalServiceAddress {
        return SignalServiceAddress(
            serviceId: Aci.parseFrom(aciString: aciString),
            legacyPhoneNumber: phoneNumber,
            cache: SSKEnvironment.shared.signalServiceAddressCacheRef,
        )
    }

    @objc
    public static func legacyAddress(serviceIdString: String?, phoneNumber: String?) -> SignalServiceAddress {
        return SignalServiceAddress(
            serviceId: serviceIdString.flatMap { try? ServiceId.parseFrom(serviceIdString: $0) },
            legacyPhoneNumber: phoneNumber,
            cache: SSKEnvironment.shared.signalServiceAddressCacheRef,
        )
    }

    public convenience init(_ e164: E164) {
        self.init(phoneNumber: e164.stringValue)
    }

    @objc
    public convenience init(phoneNumber: String) {
        self.init(serviceId: nil, phoneNumber: phoneNumber)
    }

    /// Initializes an address that should refer to an Aci.
    ///
    /// - Note: Call this only if you **expect** an `Aci` in all cases. If the
    /// value might be a Pni, you shouldn't call this method.
    @objc
    public convenience init(aciString: String) {
        self.init(aciString: aciString, phoneNumber: nil)
    }

    /// Initializes an address that should refer to an Aci.
    ///
    /// - Note: Call this only if you **expect** an `Aci` (or nil) in all cases.
    /// If the value might be a Pni, you shouldn't call this method.
    @objc
    public convenience init(aciString: String?, phoneNumber: String?) {
        self.init(serviceIdString: aciString, allowPni: false, phoneNumber: phoneNumber)
    }

    /// Initializes an address for an Aci or Pni.
    @objc
    public convenience init(serviceIdString: String) {
        self.init(serviceIdString: serviceIdString, phoneNumber: nil)
    }

    /// Initializes an address for an Aci or Pni.
    @objc
    public convenience init(serviceIdString: String?, phoneNumber: String?) {
        self.init(serviceIdString: serviceIdString, allowPni: true, phoneNumber: phoneNumber)
    }

    /// Initializes an address for an Aci or Pni.
    ///
    /// - Parameter allowPni: If false, PNIs will be treated as invalid.
    @objc
    public convenience init(serviceIdString: String?, allowPni: Bool, phoneNumber: String?) {
        self.init(
            serviceId: serviceIdString.flatMap {
                let serviceId = try? ServiceId.parseFrom(serviceIdString: $0)
                if serviceId is Aci {
                    return serviceId
                }
                if serviceId is Pni, allowPni {
                    return serviceId
                }
                owsFailDebug("Unexpectedly initialized SignalServiceAddress with invalid serviceIdString.")
                return nil
            },
            phoneNumber: phoneNumber,
        )
    }

    @objc
    public convenience init(serviceIdObjC: ServiceIdObjC) {
        self.init(serviceIdObjC.wrappedValue)
    }

    public convenience init(_ serviceId: ServiceId) {
        self.init(serviceId: serviceId, phoneNumber: nil)
    }

    public convenience init(serviceId: ServiceId?, e164: E164?) {
        self.init(serviceId: serviceId, phoneNumber: e164?.stringValue)
    }

    public convenience init(serviceId: ServiceId?, phoneNumber: String?) {
        self.init(
            serviceId: serviceId,
            phoneNumber: phoneNumber,
            isLegacyPhoneNumber: false,
            cache: SSKEnvironment.shared.signalServiceAddressCacheRef,
        )
    }

    convenience init(from address: ProtocolAddress) {
        self.init(address.serviceId)
    }

    public convenience init(
        serviceId: ServiceId?,
        phoneNumber: String?,
        cache: SignalServiceAddressCache,
    ) {
        self.init(
            serviceId: serviceId,
            phoneNumber: phoneNumber,
            isLegacyPhoneNumber: false,
            cache: cache,
        )
    }

    private init(
        serviceId: ServiceId?,
        phoneNumber: String?,
        isLegacyPhoneNumber: Bool,
        cache: SignalServiceAddressCache,
    ) {
        if let phoneNumber, phoneNumber.isEmpty {
            owsFailDebug("Unexpectedly initialized signal service address with invalid phone number")
        }

        self.cachedAddress = cache.registerAddress(
            proposedIdentifiers: CachedAddress.Identifiers(
                serviceId: serviceId,
                phoneNumber: phoneNumber,
            ),
            isLegacyPhoneNumber: isLegacyPhoneNumber,
        )

        super.init()

        if !isValid {
            owsFailDebug("Unexpectedly initialized address with no identifier")
        }
    }

    // MARK: - Codable

    private enum CodingKeys: String, CodingKey {
        case backingUuid
        case backingPhoneNumber
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        let identifiers = cachedAddress.identifiers.get()
        try container.encode(identifiers.serviceId?.serviceIdUppercaseString, forKey: .backingUuid)
        // Only encode the backingPhoneNumber if we don't know the UUID
        try container.encode(identifiers.serviceId != nil ? nil : identifiers.phoneNumber, forKey: .backingPhoneNumber)
    }

    public required convenience init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let decodedServiceId = try container.decodeIfPresent(String.self, forKey: .backingUuid).map {
            return try ServiceId.parseFrom(serviceIdString: $0)
        }
        let decodedPhoneNumber = try container.decodeIfPresent(String.self, forKey: .backingPhoneNumber)
        self.init(
            serviceId: decodedServiceId,
            legacyPhoneNumber: decodedPhoneNumber,
            cache: SSKEnvironment.shared.signalServiceAddressCacheRef,
        )
    }

    // MARK: - NSSecureCoding

    public static let supportsSecureCoding: Bool = true

    public func encode(with aCoder: NSCoder) {
        let identifiers = cachedAddress.identifiers.get()
        aCoder.encode(identifiers.serviceId.map { serviceId -> Any in
            // For now, encode Acis in a backwards-compatible manner. This can be
            // changed in the future, but it will prevent downgrades.
            switch serviceId {
            case is Aci:
                return serviceId.rawUUID
            default:
                return serviceId.serviceIdBinary
            }
        }, forKey: "backingUuid")
        // Only encode the backingPhoneNumber if we don't know the UUID
        aCoder.encode(identifiers.serviceId != nil ? nil : identifiers.phoneNumber, forKey: "backingPhoneNumber")
    }

    public required convenience init?(coder aDecoder: NSCoder) {
        let decodedServiceId: ServiceId?
        switch aDecoder.decodeObject(of: [NSUUID.self, NSData.self], forKey: "backingUuid") {
        case nil:
            decodedServiceId = nil
        case let serviceIdBinary as Data:
            do {
                decodedServiceId = try ServiceId.parseFrom(serviceIdBinary: serviceIdBinary)
            } catch {
                owsFailDebug("Couldn't parse serviceIdBinary.")
                return nil
            }
        case let deprecatedUuid as NSUUID:
            decodedServiceId = Aci(fromUUID: deprecatedUuid as UUID)
        default:
            return nil
        }
        let decodedPhoneNumber = aDecoder.decodeObject(of: NSString.self, forKey: "backingPhoneNumber") as String?
        self.init(
            serviceId: decodedServiceId,
            legacyPhoneNumber: decodedPhoneNumber,
            cache: SSKEnvironment.shared.signalServiceAddressCacheRef,
        )
    }

    // MARK: -

    @objc
    public func copy(with zone: NSZone? = nil) -> Any { return self }

    override public func isEqual(_ object: Any?) -> Bool {
        guard let otherAddress = object as? SignalServiceAddress else {
            return false
        }

        let result = isEqualToAddress(otherAddress)
        if result, cachedAddress.hashValue != otherAddress.cachedAddress.hashValue {
            Logger.warn("Equal addresses have different hashes: \(self), other: \(otherAddress).")
        }
        return result
    }

    @objc
    public func isEqualToAddress(_ otherAddress: SignalServiceAddress?) -> Bool {
        guard let otherAddress else {
            return false
        }

        let this = cachedAddress.identifiers.get()
        let other = otherAddress.cachedAddress.identifiers.get()

        if let thisServiceId = this.serviceId, let otherServiceId = other.serviceId {
            return thisServiceId == otherServiceId
        }
        if let thisPhoneNumber = this.phoneNumber, let otherPhoneNumber = other.phoneNumber {
            return thisPhoneNumber == otherPhoneNumber
        }
        return false
    }

    // In order to maintain a consistent hash, we use a constant value generated
    // by the cache that can be mapped back to the phone number OR the UUID.
    //
    // This allows us to dynamically update the backing values to maintain
    // the most complete address object as we learn phone <-> UUID mapping,
    // while also allowing addresses to live in hash tables.
    override public var hash: Int { return cachedAddress.hashValue }

    // MARK: -

    @objc
    public var isValid: Bool {
        let identifiers = cachedAddress.identifiers.get()

        if identifiers.serviceId != nil {
            return true
        }

        if let phoneNumber = identifiers.phoneNumber {
            return !phoneNumber.isEmpty
        }

        return false
    }

    @objc
    public var isLocalAddress: Bool {
        return DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress == self
    }

    @objc
    override public var description: String {
        let identifiers = cachedAddress.identifiers.get()
        return Self.addressComponentsDescription(
            uuidString: identifiers.serviceId?.logString,
            phoneNumber: identifiers.phoneNumber,
        )
    }

    public static func addressComponentsDescription(uuidString: String?, phoneNumber: String?) -> String {
        var splits = [String]()
        if let uuid = uuidString?.nilIfEmpty {
            splits.append("serviceId: " + uuid)
        } else if let phoneNumber = phoneNumber?.nilIfEmpty {
            splits.append("phoneNumber: " + phoneNumber)
        }
        return "<" + splits.joined(separator: ", ") + ">"
    }
}

// MARK: -

private class CachedAddress {
    struct Identifiers: Equatable {
        var serviceId: ServiceId?
        var phoneNumber: String?
    }

    let hashValue: Int

    let identifiers: AtomicValue<Identifiers>

    init(hashValue: Int, identifiers: Identifiers) {
        self.hashValue = hashValue
        self.identifiers = AtomicValue(identifiers, lock: .init())
    }
}

public class SignalServiceAddressCache: NSObject {
    private let state = AtomicValue(CacheState(), lock: .init())

    private let _phoneNumberVisibilityFetcher: PhoneNumberVisibilityFetcher?
    private var phoneNumberVisibilityFetcher: PhoneNumberVisibilityFetcher {
        return _phoneNumberVisibilityFetcher ?? DependenciesBridge.shared.phoneNumberVisibilityFetcher
    }

    private struct CacheState {
        var serviceIdHashValues = [ServiceId: Int]()
        var phoneNumberHashValues = [String: Int]()

        var serviceIdToPhoneNumber = [ServiceId: PotentiallyVisible<String>]()
        var phoneNumberToServiceIds = [String: [PotentiallyVisible<ServiceId>]]()

        var serviceIdCachedAddresses = [ServiceId: [CachedAddress]]()
        var phoneNumberOnlyCachedAddresses = [String: [CachedAddress]]()
        var phoneNumberOnlyLegacyCachedAddresses = [String: [CachedAddress]]()
    }

    /// Tracks a relationship that may or may not be visible.
    ///
    /// Hidden relationships are used to handle "legacy" address migrations.
    private struct PotentiallyVisible<T: Equatable>: Equatable {
        var wrappedValue: T
        var isVisible: Bool
    }

    // TODO: Remove this initializer after fixing DependenciesBridge setup.
    override public init() {
        self._phoneNumberVisibilityFetcher = nil
    }

    public init(phoneNumberVisibilityFetcher: any PhoneNumberVisibilityFetcher) {
        self._phoneNumberVisibilityFetcher = phoneNumberVisibilityFetcher
    }

    func prepareCache() {
        SSKEnvironment.shared.databaseStorageRef.read { tx in
            let bulkFetcher: BulkPhoneNumberVisibilityFetcher?
            do {
                bulkFetcher = try phoneNumberVisibilityFetcher.fetchAll(tx: tx)
            } catch {
                Logger.warn("Couldn't fetch visible phone numbers. Hiding all of them…")
                bulkFetcher = nil
            }
            let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable
            recipientDatabaseTable.enumerateAll(tx: tx) { recipient in
                updateRecipient(
                    recipient,
                    isPhoneNumberVisible: (
                        bulkFetcher?.isPhoneNumberVisible(for: recipient) ?? false,
                    ),
                )
            }
        }
    }

    /// Updates the cache to reflect `signalRecipient`.
    ///
    /// This method doesn't require a write transaction to function, but there's
    /// an assumption throughout the application that SignalServiceAddresses
    /// won't change outside of a write transaction. Therefore, this method
    /// requires one to allow the compiler to help enforce this invariant.
    public func updateRecipient(_ signalRecipient: SignalRecipient, tx: DBWriteTransaction) {
        updateRecipient(
            signalRecipient,
            isPhoneNumberVisible: phoneNumberVisibilityFetcher.isPhoneNumberVisible(for: signalRecipient, tx: tx),
        )
    }

    func updateRecipient(_ signalRecipient: SignalRecipient, isPhoneNumberVisible: Bool) {
        updateRecipient(
            aci: signalRecipient.aci,
            phoneNumber: signalRecipient.phoneNumber?.stringValue,
            pni: signalRecipient.pni,
            isPhoneNumberVisible: isPhoneNumberVisible,
        )
    }

    func updateRecipient(aci: Aci?, phoneNumber: String?, pni: Pni?, isPhoneNumberVisible: Bool) {
        state.update { cacheState in
            // This cache associates phone numbers to the other identifiers. If we
            // don't have a phone number, there's nothing to associate.
            //
            // We never remove a phone number from one recipient without *also* adding
            // it to some other recipient; therefore, we handle the transfer when that
            // recipient is passed to this method. (This avoids a potential problem
            // that could occur if we learn about the "delete" after the "update".)
            guard let phoneNumber else {
                return
            }

            let oldPotentiallyVisibleServiceIds: [PotentiallyVisible<ServiceId>] = (
                cacheState.phoneNumberToServiceIds[phoneNumber] ?? [],
            )
            let newPotentiallyVisibleServiceIds: [PotentiallyVisible<ServiceId>] = [
                aci.map { PotentiallyVisible(wrappedValue: $0, isVisible: isPhoneNumberVisible) },
                pni.map { PotentiallyVisible(wrappedValue: $0, isVisible: true) },
            ].compacted()

            // If this phone number still points at the same ServiceIds in the same
            // way, there's nothing to change.
            if newPotentiallyVisibleServiceIds == oldPotentiallyVisibleServiceIds {
                return
            }

            // Update the "phone number -> service ids" lookup table.
            cacheState.phoneNumberToServiceIds[phoneNumber] = newPotentiallyVisibleServiceIds

            // Update the "service id -> phone number" lookup table by clearing the
            // entries for any values that were removed and updating the entries for
            // any values that were added or changed.
            for oldServiceId in oldPotentiallyVisibleServiceIds {
                if newPotentiallyVisibleServiceIds.contains(where: { $0.wrappedValue == oldServiceId.wrappedValue }) {
                    continue
                }
                cacheState.serviceIdToPhoneNumber[oldServiceId.wrappedValue] = nil
            }
            for newOrUpdatedServiceId in newPotentiallyVisibleServiceIds {
                let oldPhoneNumber = cacheState.serviceIdToPhoneNumber.updateValue(
                    PotentiallyVisible(wrappedValue: phoneNumber, isVisible: newOrUpdatedServiceId.isVisible),
                    forKey: newOrUpdatedServiceId.wrappedValue,
                )
                // If this ServiceId was associated with some other phone number, we need
                // to break that association.
                if let oldPhoneNumber, oldPhoneNumber.wrappedValue != phoneNumber {
                    cacheState.phoneNumberToServiceIds[oldPhoneNumber.wrappedValue]?.removeAll(where: {
                        return $0.wrappedValue == newOrUpdatedServiceId.wrappedValue
                    })
                }
                // This might be the first time we're learning about this ServiceId or
                // phone number. If a preferred hash value is available, make sure all
                // future SignalServiceAddress instances will be able to find it.
                _ = hashValue(cacheState: &cacheState, serviceId: newOrUpdatedServiceId.wrappedValue, phoneNumber: phoneNumber)
            }

            let oldVisibleServiceIds: [ServiceId] = oldPotentiallyVisibleServiceIds.compactMap {
                return $0.isVisible ? $0.wrappedValue : nil
            }
            let newVisibleServiceIds: [ServiceId] = newPotentiallyVisibleServiceIds.compactMap {
                return $0.isVisible ? $0.wrappedValue : nil
            }

            // These ServiceIds are no longer visibly associated with `phoneNumber`.
            for serviceId in Set(oldVisibleServiceIds).subtracting(newVisibleServiceIds) {
                cacheState.serviceIdCachedAddresses[serviceId]?.forEach { cachedAddress in
                    cachedAddress.identifiers.update { $0.phoneNumber = nil }
                }
            }

            // These ServiceIds are now visibly associated with `phoneNumber`.
            for serviceId in Set(newVisibleServiceIds).subtracting(oldVisibleServiceIds) {
                cacheState.serviceIdCachedAddresses[serviceId]?.forEach { cachedAddress in
                    cachedAddress.identifiers.update { $0.phoneNumber = phoneNumber }
                }
            }

            // "Legacy" addresses can be resolved using any ServiceId.
            updatePhoneNumberOnlyAddresses(
                phoneNumberOnlyCachedAddresses: &cacheState.phoneNumberOnlyLegacyCachedAddresses,
                serviceIdCachedAddresses: &cacheState.serviceIdCachedAddresses,
                phoneNumber: phoneNumber,
                serviceId: newPotentiallyVisibleServiceIds.first,
            )
            // Other addresses can only be resolved with a visible ServiceId.
            updatePhoneNumberOnlyAddresses(
                phoneNumberOnlyCachedAddresses: &cacheState.phoneNumberOnlyCachedAddresses,
                serviceIdCachedAddresses: &cacheState.serviceIdCachedAddresses,
                phoneNumber: phoneNumber,
                serviceId: newPotentiallyVisibleServiceIds.first(where: { $0.isVisible }),
            )
        }
    }

    /// If we're adding a ServiceId to this recipient for the first time, we may
    /// have some addresses with only a phone number. We should add the "best"
    /// ServiceId available to those addresses. Once we add a ServiceId, that
    /// value is "sticky" and won't be changed if we get an even better
    /// identifier in the future. This maintains the existing (very useful)
    /// invariant that a ServiceId for a SignalServiceAddress remains stable.
    private func updatePhoneNumberOnlyAddresses(
        phoneNumberOnlyCachedAddresses: inout [String: [CachedAddress]],
        serviceIdCachedAddresses: inout [ServiceId: [CachedAddress]],
        phoneNumber: String,
        serviceId: PotentiallyVisible<ServiceId>?,
    ) {
        guard let serviceId else {
            return
        }
        phoneNumberOnlyCachedAddresses.removeValue(forKey: phoneNumber)?.forEach { cachedAddress in
            cachedAddress.identifiers.update {
                $0.serviceId = serviceId.wrappedValue
                if !serviceId.isVisible { $0.phoneNumber = nil }
            }
            // This address has a serviceId now -- track that serviceId for future updates.
            serviceIdCachedAddresses[serviceId.wrappedValue, default: []].append(cachedAddress)
        }
    }

    fileprivate func registerAddress(proposedIdentifiers: CachedAddress.Identifiers, isLegacyPhoneNumber: Bool) -> CachedAddress {
        state.update { cacheState in
            let resolvedIdentifiers = resolveIdentifiers(
                proposedIdentifiers,
                isLegacyPhoneNumber: isLegacyPhoneNumber,
                cacheState: cacheState,
            )

            // We try our best to share hash values for ServiceIds and phone numbers
            // that might be associated with one another.
            let hashValue = hashValue(
                cacheState: &cacheState,
                serviceId: resolvedIdentifiers.serviceId,
                phoneNumber: resolvedIdentifiers.phoneNumber,
            )

            func getOrCreateCachedAddress<T>(key: T, in cachedAddresses: inout [T: [CachedAddress]]) -> CachedAddress {
                for cachedAddress in cachedAddresses[key, default: []] {
                    if cachedAddress.hashValue == hashValue, cachedAddress.identifiers.get() == resolvedIdentifiers {
                        return cachedAddress
                    }
                }
                let result = CachedAddress(hashValue: hashValue, identifiers: resolvedIdentifiers)
                cachedAddresses[key, default: []].append(result)
                return result
            }

            if let serviceId = resolvedIdentifiers.serviceId {
                return getOrCreateCachedAddress(key: serviceId, in: &cacheState.serviceIdCachedAddresses)
            }
            if let phoneNumber = resolvedIdentifiers.phoneNumber {
                if isLegacyPhoneNumber {
                    return getOrCreateCachedAddress(key: phoneNumber, in: &cacheState.phoneNumberOnlyLegacyCachedAddresses)
                } else {
                    return getOrCreateCachedAddress(key: phoneNumber, in: &cacheState.phoneNumberOnlyCachedAddresses)
                }
            }
            return CachedAddress(hashValue: hashValue, identifiers: resolvedIdentifiers)
        }
    }

    /// Populates missing/stale identifiers populated from the cache.
    ///
    /// - Parameter isLegacyPhoneNumber: If true, phone numbers can be resolved
    /// to hidden ACIs. This ensures legacy values (eg, receipts for old
    /// messages) will continue to associate with the correct account. In these
    /// cases, the returned identifiers won't contain the proposed phone number.
    /// If false, phone numbers can't be resolved to hidden ACIs (but they can
    /// be resolved to PNIs which are always visible to phone numbers).
    private func resolveIdentifiers(
        _ proposedIdentifiers: CachedAddress.Identifiers,
        isLegacyPhoneNumber: Bool,
        cacheState: CacheState,
    ) -> CachedAddress.Identifiers {
        var resolvedIdentifiers = proposedIdentifiers
        resolveServiceId(
            in: &resolvedIdentifiers,
            isLegacyPhoneNumber: isLegacyPhoneNumber,
            cacheState: cacheState,
        )
        resolvePhoneNumber(in: &resolvedIdentifiers, cacheState: cacheState)
        return resolvedIdentifiers
    }

    private func resolveServiceId(
        in identifiers: inout CachedAddress.Identifiers,
        isLegacyPhoneNumber: Bool,
        cacheState: CacheState,
    ) {
        guard identifiers.serviceId == nil, let phoneNumber = identifiers.phoneNumber else {
            return
        }
        for serviceId in cacheState.phoneNumberToServiceIds[phoneNumber] ?? [] {
            if serviceId.isVisible {
                identifiers.serviceId = serviceId.wrappedValue
                return
            }
            // A "legacy" value can be resolved, but we know it's hidden (because the
            // prior check didn't pass), so clear the phone number.
            if isLegacyPhoneNumber {
                identifiers.serviceId = serviceId.wrappedValue
                identifiers.phoneNumber = nil
                return
            }
        }
    }

    private func resolvePhoneNumber(
        in identifiers: inout CachedAddress.Identifiers,
        cacheState: CacheState,
    ) {
        guard identifiers.phoneNumber == nil, let serviceId = identifiers.serviceId else {
            return
        }
        if let phoneNumber = cacheState.serviceIdToPhoneNumber[serviceId] {
            if phoneNumber.isVisible {
                identifiers.phoneNumber = phoneNumber.wrappedValue
                return
            }
            // Unlike the prior method, we don't need special handling for
            // isLegacyPhoneNumber in this case. The goal for "legacy" values is to
            // resolve to an ACI, but if we reach this point, `identifier.serviceId` is
            // an ACI (because PNIs don't set isVisible to false) and
            // `identifier.phoneNumber` is nil.
        }
    }

    /// Finds the best hash value for (serviceId, phoneNumber).
    ///
    /// In general, we'll return an existing hash value if one exists, and we'll
    /// generate a new random hash value if one doesn't exist. If we generate a
    /// random hash value, we associate it with the provided identifier(s) for
    /// future calls to this method.
    ///
    /// Some edge cases worth documenting explicitly:
    ///
    /// - If both identifiers are nonnil and have different hash values, the
    /// hash value for `serviceId` will be returned. The hash value for
    /// `phoneNumber` won't be updated for future calls to this method.
    ///
    /// - If both identifiers are nonnil and only one of them has a hash value,
    /// that value will be returned. The returned hash value will also be
    /// associated with the other identifier for future calls to this method.
    ///
    /// - If both identifiers are nil, this method is equivalent to
    /// `Int.random(in: Int.min...Int.max)`. An address must have at least one
    /// identifier to be considered valid, and addresses without identifiers
    /// always return `false` from `isEqual:`, so it's perfectly acceptable for
    /// each of these addresses to have its own hash value.
    private func hashValue(cacheState: inout CacheState, serviceId: ServiceId?, phoneNumber: String?) -> Int {
        let hashValue = (
            serviceId.flatMap { cacheState.serviceIdHashValues[$0] }
                ?? phoneNumber.flatMap { cacheState.phoneNumberHashValues[$0] }
                ?? Int.random(in: Int.min...Int.max),
        )
        // We *never* change a hash value once it's been generated.
        if let serviceId, cacheState.serviceIdHashValues[serviceId] == nil {
            cacheState.serviceIdHashValues[serviceId] = hashValue
        }
        if let phoneNumber, cacheState.phoneNumberHashValues[phoneNumber] == nil {
            cacheState.phoneNumberHashValues[phoneNumber] = hashValue
        }
        return hashValue
    }
}

// MARK: - Unit Tests

#if TESTABLE_BUILD

extension SignalServiceAddress {
    static func randomForTesting() -> SignalServiceAddress { SignalServiceAddress(Aci.randomForTesting()) }

    static func isolatedRandomForTesting() -> SignalServiceAddress {
        SignalServiceAddress(
            serviceId: Aci.randomForTesting(),
            phoneNumber: nil,
            cache: SignalServiceAddressCache(),
        )
    }

    static func isolatedForTesting(
        serviceId: ServiceId? = nil,
        phoneNumber: String? = nil,
    ) -> SignalServiceAddress {
        SignalServiceAddress(
            serviceId: serviceId,
            phoneNumber: phoneNumber,
            cache: SignalServiceAddressCache(),
        )
    }
}

extension SignalServiceAddressCache {
    func makeAddress(serviceId: ServiceId?, phoneNumber: E164?) -> SignalServiceAddress {
        SignalServiceAddress(
            serviceId: serviceId,
            phoneNumber: phoneNumber?.stringValue,
            cache: self,
        )
    }
}

#endif