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

public import LibSignalClient
public import SignalServiceKit

public struct UsernameQuerier {
    private let contactsManager: any ContactManager
    private let db: DB
    private let localUsernameManager: LocalUsernameManager
    private let networkManager: NetworkManager
    private let profileManager: ProfileManager
    private let recipientManager: any SignalRecipientManager
    private let recipientFetcher: RecipientFetcher
    private let storageServiceManager: StorageServiceManager
    private let tsAccountManager: TSAccountManager
    private let usernameApiClient: UsernameApiClient
    private let usernameLinkManager: UsernameLinkManager
    private let usernameLookupManager: UsernameLookupManager

    public init() {
        self.init(
            contactsManager: SSKEnvironment.shared.contactManagerRef,
            db: DependenciesBridge.shared.db,
            localUsernameManager: DependenciesBridge.shared.localUsernameManager,
            networkManager: SSKEnvironment.shared.networkManagerRef,
            profileManager: SSKEnvironment.shared.profileManagerRef,
            recipientManager: DependenciesBridge.shared.recipientManager,
            recipientFetcher: DependenciesBridge.shared.recipientFetcher,
            storageServiceManager: SSKEnvironment.shared.storageServiceManagerRef,
            tsAccountManager: DependenciesBridge.shared.tsAccountManager,
            usernameApiClient: DependenciesBridge.shared.usernameApiClient,
            usernameLinkManager: DependenciesBridge.shared.usernameLinkManager,
            usernameLookupManager: DependenciesBridge.shared.usernameLookupManager,
        )
    }

    public init(
        contactsManager: any ContactManager,
        db: DB,
        localUsernameManager: LocalUsernameManager,
        networkManager: NetworkManager,
        profileManager: ProfileManager,
        recipientManager: any SignalRecipientManager,
        recipientFetcher: RecipientFetcher,
        storageServiceManager: StorageServiceManager,
        tsAccountManager: TSAccountManager,
        usernameApiClient: UsernameApiClient,
        usernameLinkManager: UsernameLinkManager,
        usernameLookupManager: UsernameLookupManager,
    ) {
        self.contactsManager = contactsManager
        self.db = db
        self.localUsernameManager = localUsernameManager
        self.networkManager = networkManager
        self.profileManager = profileManager
        self.recipientManager = recipientManager
        self.recipientFetcher = recipientFetcher
        self.storageServiceManager = storageServiceManager
        self.tsAccountManager = tsAccountManager
        self.usernameApiClient = usernameApiClient
        self.usernameLinkManager = usernameLinkManager
        self.usernameLookupManager = usernameLookupManager
    }

    // MARK: -

    /// Query for the username via the given link, internally handling
    /// displaying errors as appropriate. Callers should do nothing if this
    /// method returns `nil`.
    @MainActor
    public func queryForUsernameLink(
        link: Usernames.UsernameLink,
        fromViewController: UIViewController,
        failureSheetDismissalDelegate: SheetDismissalDelegate? = nil,
    ) async -> (username: String, Aci)? {
        do throws(SheetDisplayableError) {
            return try await _queryForUsernameLink(link: link, fromViewController: fromViewController)
        } catch {
            error.showSheet(from: fromViewController, dismissalDelegate: failureSheetDismissalDelegate)
            return nil
        }
    }

    private func _queryForUsernameLink(
        link: Usernames.UsernameLink,
        fromViewController: UIViewController,
    ) async throws(SheetDisplayableError) -> (username: String, Aci) {
        let (localAci, localLink, localUsername): (
            Aci?,
            Usernames.UsernameLink?,
            String?,
        ) = db.read { tx in
            let usernameState = localUsernameManager.usernameState(tx: tx)
            return (
                tsAccountManager.localIdentifiers(tx: tx)?.aci,
                usernameState.usernameLink,
                usernameState.username,
            )
        }

        if
            let localAci,
            let localLink,
            let localUsername,
            localLink == link
        {
            return (localUsername, localAci)
        }

        return try await ModalActivityIndicatorViewController.presentAndPropagateResult(
            from: fromViewController,
            title: CommonStrings.searchingModal,
            canCancel: true,
        ) { () throws(SheetDisplayableError) -> (username: String, Aci) in
            let username: String?
            do {
                username = try await usernameLinkManager.decryptEncryptedLink(link: link)
            } catch is CancellationError {
                throw .userCancelled
            } catch where error.isNetworkFailureOrTimeout {
                throw .networkError
            } catch {
                Logger.warn("Failed to decrypt username link with generic error! \(error)")
                throw .usernameLookupGenericError()
            }

            guard let username else {
                throw .usernameLinkNoLongerValidError()
            }

            guard
                let hashedUsername = try? Usernames.HashedUsername(
                    forUsername: username,
                )
            else {
                throw .usernameInvalidError(username)
            }

            do {
                let usernameAci = try await queryServiceForUsername(hashedUsername: hashedUsername)
                return (username, usernameAci)
            } catch is CancellationError {
                throw .userCancelled
            } catch is UsernameNotFoundError {
                throw .usernameNotFoundError(username)
            } catch where error.isNetworkFailureOrTimeout {
                throw .networkError
            } catch {
                Logger.warn("Failed to look up username for link with generic error! \(error)")
                throw .usernameLookupGenericError()
            }
        }
    }

    // MARK: -

    /// Query for the given username, internally handling displaying errors as
    /// appropriate. Callers should do nothing if this method returns `nil`.
    @MainActor
    public func queryForUsername(
        username: String,
        fromViewController: UIViewController,
        failureSheetDismissalDelegate: SheetDismissalDelegate? = nil,
    ) async -> Aci? {
        do throws(SheetDisplayableError) {
            return try await _queryForUsername(username: username, fromViewController: fromViewController)
        } catch {
            error.showSheet(from: fromViewController, dismissalDelegate: failureSheetDismissalDelegate)
            return nil
        }
    }

    private func _queryForUsername(
        username: String,
        fromViewController: UIViewController,
    ) async throws(SheetDisplayableError) -> Aci {
        let (localAci, localUsername): (Aci?, String?) = db.read { tx in
            return (
                tsAccountManager.localIdentifiers(tx: tx)?.aci,
                localUsernameManager.usernameState(tx: tx).username,
            )
        }

        if
            let localAci,
            let localUsername,
            localUsername.caseInsensitiveCompare(username) == .orderedSame
        {
            return localAci
        }

        return try await ModalActivityIndicatorViewController.presentAndPropagateResult(
            from: fromViewController,
            title: CommonStrings.searchingModal,
            canCancel: true,
        ) { () throws(SheetDisplayableError) -> Aci in
            guard
                let hashedUsername = try? Usernames.HashedUsername(
                    forUsername: username,
                )
            else {
                throw .usernameInvalidError(username)
            }

            do {
                return try await queryServiceForUsername(hashedUsername: hashedUsername)
            } catch is CancellationError {
                throw .userCancelled
            } catch is UsernameNotFoundError {
                throw .usernameNotFoundError(username)
            } catch where error.isNetworkFailureOrTimeout {
                throw .networkError
            } catch {
                Logger.warn("Failed to query username with generic error! \(error)")
                throw .usernameLookupGenericError()
            }
        }
    }

    // MARK: -

    private struct UsernameNotFoundError: Error {}

    /// Query the service for the ACI of the given username.
    private func queryServiceForUsername(hashedUsername: Usernames.HashedUsername) async throws -> Aci {
        let aci = try await self.usernameApiClient.lookupAci(forHashedUsername: hashedUsername)
        guard let aci else {
            throw UsernameNotFoundError()
        }

        await db.awaitableWrite { tx in
            handleUsernameLookupCompleted(
                aci: aci,
                username: hashedUsername.usernameString,
                tx: tx,
            )
        }
        return aci
    }

    private func handleUsernameLookupCompleted(
        aci: Aci,
        username: String,
        tx: DBWriteTransaction,
    ) {
        var recipient = recipientFetcher.fetchOrCreate(serviceId: aci, tx: tx)
        recipientManager.markAsRegisteredAndSave(&recipient, shouldUpdateStorageService: true, tx: tx)

        let isUsernameBestIdentifier = Usernames.BetterIdentifierChecker.assembleByQuerying(
            forRecipient: recipient,
            profileManager: profileManager,
            contactManager: contactsManager,
            transaction: tx,
        ).usernameIsBestIdentifier()

        if isUsernameBestIdentifier {
            // If this username is the best identifier we have for this
            // address, we should save it locally and in StorageService.

            usernameLookupManager.saveUsername(
                username,
                forAci: aci,
                transaction: tx,
            )

            storageServiceManager.recordPendingUpdates(updatedRecipientUniqueIds: [recipient.uniqueId])
        } else {
            // If we have a better identifier for this address, we can
            // throw away any stored username info for it.

            usernameLookupManager.saveUsername(
                nil,
                forAci: aci,
                transaction: tx,
            )
        }
    }
}

// MARK: -

private extension SheetDisplayableError {
    static func usernameInvalidError(_ username: String) -> SheetDisplayableError {
        return ActionSheetDisplayableError(
            localizedTitle: OWSLocalizedString(
                "USERNAME_LOOKUP_INVALID_USERNAME_TITLE",
                comment: "Title for an action sheet indicating that a user-entered username value is not a valid username.",
            ),
            localizedMessage: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "USERNAME_LOOKUP_INVALID_USERNAME_MESSAGE_FORMAT",
                    comment: "A message indicating that a user-entered username value is not a valid username. Embeds {{ a username }}.",
                ),
                username,
            ),
        )
    }

    static func usernameNotFoundError(_ username: String) -> SheetDisplayableError {
        return ActionSheetDisplayableError(
            localizedTitle: OWSLocalizedString(
                "USERNAME_LOOKUP_NOT_FOUND_TITLE",
                comment: "Title for an action sheet indicating that the given username is not associated with a registered Signal account.",
            ),
            localizedMessage: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "USERNAME_LOOKUP_NOT_FOUND_MESSAGE_FORMAT",
                    comment: "A message indicating that the given username is not associated with a registered Signal account. Embeds {{ a username }}.",
                ),
                username,
            ),
        )
    }

    static func usernameLinkNoLongerValidError() -> SheetDisplayableError {
        return ActionSheetDisplayableError(
            localizedTitle: CommonStrings.errorAlertTitle,
            localizedMessage: OWSLocalizedString(
                "USERNAME_LOOKUP_LINK_NO_LONGER_VALID_MESSAGE",
                comment: "A message indicating that a username link the user attempted to query is no longer valid.",
            ),
        )
    }

    static func usernameLookupGenericError() -> SheetDisplayableError {
        return ActionSheetDisplayableError(localizedMessage: OWSLocalizedString(
            "USERNAME_LOOKUP_ERROR_MESSAGE",
            comment: "A message indicating that username lookup failed.",
        ))
    }
}