Asyncify UsernameQuerier, use ActionSheetDisplayableError

This commit is contained in:
Sasha Weiss
2025-11-20 09:59:42 -08:00
committed by GitHub
parent ddabe7041e
commit 4c6614668d
10 changed files with 228 additions and 227 deletions

View File

@@ -646,17 +646,17 @@ extension ConversationViewController: CVComponentDelegate {
}
public func didTapUsernameLink(usernameLink: Usernames.UsernameLink) {
SSKEnvironment.shared.databaseStorageRef.read { tx in
UsernameQuerier().queryForUsernameLink(
Task {
guard let (_, aci) = await UsernameQuerier().queryForUsernameLink(
link: usernameLink,
fromViewController: self,
tx: tx,
onSuccess: { _, aci in
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(aci),
animated: true
)
}
) else {
return
}
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(aci),
animated: true,
)
}
}

View File

@@ -152,17 +152,18 @@ class UrlOpener {
SignalDotMePhoneNumberLink.openChat(url: url, fromViewController: rootViewController)
case .usernameLink(let link):
databaseStorage.read { tx in
UsernameQuerier().queryForUsernameLink(
Task {
guard let (_, aci) = await UsernameQuerier().queryForUsernameLink(
link: link,
fromViewController: rootViewController,
tx: tx
) { _, aci in
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(aci),
animated: true
)
) else {
return
}
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(aci),
animated: true,
)
}
case .stickerPack(let stickerPackInfo):

View File

@@ -44,20 +44,19 @@ extension BaseMemberViewController: UsernameLinkScanDelegate {}
extension UsernameLinkScanDelegate where Self: RecipientPickerDelegate & RecipientPickerContainerViewController {
func usernameLinkScanned(_ usernameLink: Usernames.UsernameLink) {
dismiss(animated: true) {
SSKEnvironment.shared.databaseStorageRef.read { tx in
MainActor.assumeIsolated {
UsernameQuerier().queryForUsernameLink(
link: usernameLink,
fromViewController: self,
tx: tx
) { _, aci in
self.recipientPicker(
self.recipientPicker,
didSelectRecipient: .for(address: SignalServiceAddress(aci))
)
}
dismiss(animated: true) { [self] in
Task { @MainActor in
guard let (_, aci) = await UsernameQuerier().queryForUsernameLink(
link: usernameLink,
fromViewController: self,
) else {
return
}
recipientPicker(
recipientPicker,
didSelectRecipient: .for(address: SignalServiceAddress(aci))
)
}
}
}

View File

@@ -595,17 +595,18 @@ extension AppSettingsViewController: UsernameLinkScanDelegate {
}
presentingViewController.dismiss(animated: true) {
SSKEnvironment.shared.databaseStorageRef.read { tx in
UsernameQuerier().queryForUsernameLink(
Task {
guard let (_, aci) = await UsernameQuerier().queryForUsernameLink(
link: usernameLink,
fromViewController: presentingViewController,
tx: tx
) { _, aci in
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(aci),
animated: true
)
) else {
return
}
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(aci),
animated: true,
)
}
}
}

View File

@@ -1299,14 +1299,16 @@ extension PhotoCaptureViewController: QRCodeSampleBufferScannerDelegate {
{
qrCodeScanned = true
SSKEnvironment.shared.databaseStorageRef.read { tx in
UsernameQuerier().queryForUsernameLink(
Task {
guard let (username, aci) = await UsernameQuerier().queryForUsernameLink(
link: usernameLink,
fromViewController: self,
tx: tx,
failureSheetDismissalDelegate: self,
onSuccess: self.showUsernameLinkSheet(username:aci:)
)
) else {
return
}
showUsernameLinkSheet(username: username, aci: aci)
}
} else if
let provisioningURL = DeviceProvisioningURL(urlString: qrCodeString),

View File

@@ -15,12 +15,16 @@ public enum ActionSheetDisplayableError: Error {
/// manually cancelled makes it obvious why a given action was aborted.
case userCancelled
public func showActionSheet(from fromViewController: UIViewController?) {
public func showActionSheet(
from fromViewController: UIViewController?,
dismissalDelegate: SheetDismissalDelegate? = nil,
) {
if let localizedActionSheetMessage {
OWSActionSheets.showActionSheet(
title: localizedActionSheetTitle,
message: localizedActionSheetMessage,
fromViewController: fromViewController,
dismissalDelegate: dismissalDelegate,
)
}
}

View File

@@ -1430,18 +1430,15 @@ extension RecipientPickerViewController {
}
private func findByUsername(_ username: String) {
SSKEnvironment.shared.databaseStorageRef.read { tx in
UsernameQuerier().queryForUsername(
Task {
guard let aci = await UsernameQuerier().queryForUsername(
username: username,
fromViewController: self,
tx: tx,
onSuccess: { [weak self] aci in
AssertIsOnMainThread()
) else {
return
}
guard let self else { return }
self.tryToSelectRecipient(.for(address: SignalServiceAddress(aci)))
}
)
tryToSelectRecipient(.for(address: SignalServiceAddress(aci)))
}
}
}

View File

@@ -141,20 +141,19 @@ public class FindByUsernameViewController: OWSTableViewController2 {
private func didTapNext() {
let usernameValue = self.usernameValue
usernameTextField.resignFirstResponder()
SSKEnvironment.shared.databaseStorageRef.read { tx in
UsernameQuerier().queryForUsername(
Task {
guard let aci = await UsernameQuerier().queryForUsername(
username: usernameValue,
fromViewController: self,
tx: tx,
failureSheetDismissalDelegate: self,
onSuccess: { [weak self] aci in
AssertIsOnMainThread()
self?.findByUsernameDelegate?.findByUsername(address: SignalServiceAddress(aci))
}
)
) else {
return
}
findByUsernameDelegate?.findByUsername(address: SignalServiceAddress(aci))
}
}
}
extension FindByUsernameViewController: SheetDismissalDelegate {

View File

@@ -8,7 +8,7 @@ public import SignalServiceKit
public struct UsernameQuerier {
private let contactsManager: any ContactManager
private let databaseStorage: SDSDatabaseStorage
private let db: DB
private let localUsernameManager: LocalUsernameManager
private let networkManager: NetworkManager
private let profileManager: ProfileManager
@@ -23,7 +23,7 @@ public struct UsernameQuerier {
public init() {
self.init(
contactsManager: SSKEnvironment.shared.contactManagerRef,
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
db: DependenciesBridge.shared.db,
localUsernameManager: DependenciesBridge.shared.localUsernameManager,
networkManager: SSKEnvironment.shared.networkManagerRef,
profileManager: SSKEnvironment.shared.profileManagerRef,
@@ -39,7 +39,7 @@ public struct UsernameQuerier {
public init(
contactsManager: any ContactManager,
databaseStorage: SDSDatabaseStorage,
db: DB,
localUsernameManager: LocalUsernameManager,
networkManager: NetworkManager,
profileManager: ProfileManager,
@@ -52,7 +52,7 @@ public struct UsernameQuerier {
usernameLookupManager: UsernameLookupManager
) {
self.contactsManager = contactsManager
self.databaseStorage = databaseStorage
self.db = db
self.localUsernameManager = localUsernameManager
self.networkManager = networkManager
self.profileManager = profileManager
@@ -65,144 +65,168 @@ public struct UsernameQuerier {
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,
tx: DBReadTransaction,
failureSheetDismissalDelegate: (any SheetDismissalDelegate)? = nil,
onSuccess: @escaping (_ username: String, _ aci: Aci) -> Void
) {
let usernameState = localUsernameManager.usernameState(tx: tx)
if
let localAci = tsAccountManager.localIdentifiers(tx: tx)?.aci,
let localLink = usernameState.usernameLink,
let localUsername = usernameState.username,
localLink == link
{
queryMatchedLocalUser(
onSuccess: { onSuccess(localUsername, $0) },
localAci: localAci,
tx: tx
)
return
failureSheetDismissalDelegate: SheetDismissalDelegate? = nil,
) async -> (username: String, Aci)? {
do throws(ActionSheetDisplayableError) {
return try await _queryForUsernameLink(link: link, fromViewController: fromViewController)
} catch {
error.showActionSheet(from: fromViewController, dismissalDelegate: failureSheetDismissalDelegate)
return nil
}
ModalActivityIndicatorViewController.present(
fromViewController: fromViewController,
canCancel: true,
asyncBlock: { modal in
do {
let username = try await usernameLinkManager.decryptEncryptedLink(link: link)
guard let username else {
modal.dismissIfNotCanceled {
showUsernameLinkOutdatedError(dismissalDelegate: failureSheetDismissalDelegate)
}
return
}
guard let hashedUsername = try? Usernames.HashedUsername(
forUsername: username
) else {
modal.dismissIfNotCanceled {
showInvalidUsernameError(username: username, dismissalDelegate: failureSheetDismissalDelegate)
}
return
}
let usernameAci = try await queryServiceForUsername(hashedUsername: hashedUsername)
modal.dismissIfNotCanceled {
onSuccess(hashedUsername.usernameString, usernameAci)
}
} catch {
modal.dismissIfNotCanceled {
handleError(error, dismissalDelegate: failureSheetDismissalDelegate)
}
}
}
)
}
/// Query the service for the given username, invoking a callback if the
/// username is successfully resolved to an ACI.
///
/// - Parameter onSuccess
/// A callback invoked if the queried username resolves to an ACI.
/// Guaranteed to be called on the main thread.
private func _queryForUsernameLink(
link: Usernames.UsernameLink,
fromViewController: UIViewController,
) async throws(ActionSheetDisplayableError) -> (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,
canCancel: true,
) { () throws(ActionSheetDisplayableError) -> (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,
tx: DBReadTransaction,
failureSheetDismissalDelegate: (any SheetDismissalDelegate)? = nil,
onSuccess: @escaping (Aci) -> Void
) {
failureSheetDismissalDelegate: SheetDismissalDelegate? = nil,
) async -> Aci? {
do throws(ActionSheetDisplayableError) {
return try await _queryForUsername(username: username, fromViewController: fromViewController)
} catch {
error.showActionSheet(from: fromViewController, dismissalDelegate: failureSheetDismissalDelegate)
return nil
}
}
private func _queryForUsername(
username: String,
fromViewController: UIViewController,
) async throws(ActionSheetDisplayableError) -> Aci {
let (localAci, localUsername): (Aci?, String?) = db.read { tx in
return (
tsAccountManager.localIdentifiers(tx: tx)?.aci,
localUsernameManager.usernameState(tx: tx).username,
)
}
if
let localAci = tsAccountManager.localIdentifiers(tx: tx)?.aci,
let localUsername = localUsernameManager.usernameState(tx: tx).username,
let localAci,
let localUsername,
localUsername.caseInsensitiveCompare(username) == .orderedSame
{
queryMatchedLocalUser(onSuccess: onSuccess, localAci: localAci, tx: tx)
return
return localAci
}
guard let hashedUsername = try? Usernames.HashedUsername(
forUsername: username
) else {
showInvalidUsernameError(
username: username,
dismissalDelegate: failureSheetDismissalDelegate
)
return
}
ModalActivityIndicatorViewController.present(
fromViewController: fromViewController,
return try await ModalActivityIndicatorViewController.presentAndPropagateResult(
from: fromViewController,
canCancel: true,
asyncBlock: { modal in
do {
let aci = try await queryServiceForUsername(hashedUsername: hashedUsername)
modal.dismissIfNotCanceled {
onSuccess(aci)
}
} catch {
modal.dismissIfNotCanceled {
handleError(error, dismissalDelegate: failureSheetDismissalDelegate)
}
}
) { () throws(ActionSheetDisplayableError) -> Aci in
guard let hashedUsername = try? Usernames.HashedUsername(
forUsername: username
) else {
throw .usernameInvalidError(username)
}
)
}
/// Handle a query that we know will match the local user.
///
/// - Parameter tx
/// An unused database transaction. Forced as a parameter here to draw
/// attention to the fact that this workaround is required because the query
/// methods are within the context of a transaction.
private func queryMatchedLocalUser(
onSuccess: @escaping (Aci) -> Void,
localAci: Aci,
tx _: DBReadTransaction
) {
// Dispatch asynchronously, since we are inside a transaction.
DispatchQueue.main.async {
onSuccess(localAci)
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()
}
}
}
private struct UsernameNotFoundError: Error {
var usernameString: String
}
// 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(usernameString: hashedUsername.usernameString)
throw UsernameNotFoundError()
}
await self.databaseStorage.awaitableWrite { tx in
self.handleUsernameLookupCompleted(
await db.awaitableWrite { tx in
handleUsernameLookupCompleted(
aci: aci,
username: hashedUsername.usernameString,
tx: tx
@@ -248,84 +272,57 @@ public struct UsernameQuerier {
)
}
}
}
// MARK: - Errors
// MARK: -
private func showInvalidUsernameError(
username: String,
dismissalDelegate: (any SheetDismissalDelegate)?
) {
OWSActionSheets.showActionSheet(
title: OWSLocalizedString(
private extension ActionSheetDisplayableError {
static func usernameInvalidError(_ username: String) -> ActionSheetDisplayableError {
return .custom(
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."
),
message: String(
localizedMessage: String(
format: 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
),
dismissalDelegate: dismissalDelegate
username,
)
)
}
private func showUsernameNotFoundError(
username: String,
dismissalDelegate: (any SheetDismissalDelegate)?
) {
OWSActionSheets.showActionSheet(
title: OWSLocalizedString(
static func usernameNotFoundError(_ username: String) -> ActionSheetDisplayableError {
return .custom(
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."
),
message: String(
localizedMessage: String(
format: 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
username,
),
dismissalDelegate: dismissalDelegate
)
}
private func showUsernameLinkOutdatedError(
dismissalDelegate: (any SheetDismissalDelegate)?
) {
OWSActionSheets.showActionSheet(
title: CommonStrings.errorAlertTitle,
message: OWSLocalizedString(
static func usernameLinkNoLongerValidError() -> ActionSheetDisplayableError {
return .custom(
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."
),
dismissalDelegate: dismissalDelegate
)
}
private func handleError(
_ error: any Error,
dismissalDelegate: (any SheetDismissalDelegate)?,
) {
if let notFoundError = error as? UsernameNotFoundError {
showUsernameNotFoundError(username: notFoundError.usernameString, dismissalDelegate: dismissalDelegate)
} else {
showGenericError(dismissalDelegate: dismissalDelegate)
}
}
private func showGenericError(
dismissalDelegate: (any SheetDismissalDelegate)?
) {
Logger.error("Error while querying for username!")
OWSActionSheets.showErrorAlert(
message: OWSLocalizedString(
"USERNAME_LOOKUP_ERROR_MESSAGE",
comment: "A message indicating that username lookup failed."
),
dismissalDelegate: dismissalDelegate
)
static func usernameLookupGenericError() -> ActionSheetDisplayableError {
return .custom(localizedMessage: OWSLocalizedString(
"USERNAME_LOOKUP_ERROR_MESSAGE",
comment: "A message indicating that username lookup failed."
))
}
}

View File

@@ -131,13 +131,14 @@ public class ModalActivityIndicatorViewController: OWSViewController {
@MainActor
public class func presentAndPropagateResult<T, E>(
from viewController: UIViewController,
canCancel: Bool = false,
presentationDelay: TimeInterval = Constants.defaultPresentationDelay,
wrappedAsyncBlock: @escaping () async throws(E) -> T
) async throws(E) -> T {
let result: Result<T, E> = await withCheckedContinuation { continuation in
present(
fromViewController: viewController,
canCancel: false,
canCancel: canCancel,
presentationDelay: presentationDelay,
asyncBlock: { modal in
let result = await Result(catching: wrappedAsyncBlock)