290 lines
11 KiB
Swift
290 lines
11 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
private enum OpenableUrl {
|
|
case phoneNumberLink(URL)
|
|
case usernameLink(Usernames.UsernameLink)
|
|
case stickerPack(StickerPackInfo)
|
|
case groupInvite(URL)
|
|
case signalProxy(URL)
|
|
case linkDevice
|
|
case completeIDEALDonation(Stripe.IDEALCallbackType)
|
|
case callLink(CallLink)
|
|
case quickRestore(URL)
|
|
}
|
|
|
|
class UrlOpener {
|
|
private let appReadiness: AppReadinessSetter
|
|
private let databaseStorage: SDSDatabaseStorage
|
|
private let tsAccountManager: TSAccountManager
|
|
|
|
init(
|
|
appReadiness: AppReadinessSetter,
|
|
databaseStorage: SDSDatabaseStorage,
|
|
tsAccountManager: TSAccountManager,
|
|
) {
|
|
self.appReadiness = appReadiness
|
|
self.databaseStorage = databaseStorage
|
|
self.tsAccountManager = tsAccountManager
|
|
}
|
|
|
|
// MARK: - Constants
|
|
|
|
enum Constants {
|
|
static let sgnlPrefix = "sgnl"
|
|
}
|
|
|
|
// MARK: - Parsing URLs
|
|
|
|
struct ParsedUrl {
|
|
fileprivate let openableUrl: OpenableUrl
|
|
}
|
|
|
|
static func parseUrl(_ url: URL) -> ParsedUrl? {
|
|
guard let openableUrl = parseOpenableUrl(url) else {
|
|
return nil
|
|
}
|
|
return ParsedUrl(openableUrl: openableUrl)
|
|
}
|
|
|
|
private static func parseOpenableUrl(_ url: URL) -> OpenableUrl? {
|
|
if SignalDotMePhoneNumberLink.isPossibleUrl(url) {
|
|
return .phoneNumberLink(url)
|
|
}
|
|
if let usernameLink = Usernames.UsernameLink(usernameLinkUrl: url) {
|
|
return .usernameLink(usernameLink)
|
|
}
|
|
if StickerPackInfo.isStickerPackShare(url), let stickerPackInfo = StickerPackInfo.parseStickerPackShare(url) {
|
|
return .stickerPack(stickerPackInfo)
|
|
}
|
|
if let stickerPackInfo = parseSgnlAddStickersUrl(url) {
|
|
return .stickerPack(stickerPackInfo)
|
|
}
|
|
if GroupManager.isPossibleGroupInviteLink(url) {
|
|
return .groupInvite(url)
|
|
}
|
|
if SignalProxy.isValidProxyLink(url) {
|
|
return .signalProxy(url)
|
|
}
|
|
if let linkDeviceURL = isSgnlLinkDeviceUrl(url) {
|
|
switch linkDeviceURL.linkType {
|
|
case .linkDevice: return .linkDevice
|
|
case .quickRestore: return .quickRestore(url)
|
|
}
|
|
}
|
|
if let donationType = Stripe.parseStripeIDEALCallback(url) {
|
|
return .completeIDEALDonation(donationType)
|
|
}
|
|
if let callLink = CallLink(url: url) {
|
|
return .callLink(callLink)
|
|
}
|
|
owsFailDebug("Couldn't parse URL")
|
|
return nil
|
|
}
|
|
|
|
private static func parseSgnlAddStickersUrl(_ url: URL) -> StickerPackInfo? {
|
|
guard
|
|
let components = URLComponents(string: url.absoluteString),
|
|
components.scheme == Constants.sgnlPrefix,
|
|
components.host?.hasPrefix("addstickers") == true,
|
|
let queryItems = components.queryItems
|
|
else {
|
|
return nil
|
|
}
|
|
var packIdHex: String?
|
|
var packKeyHex: String?
|
|
for queryItem in queryItems {
|
|
switch queryItem.name {
|
|
case "pack_id":
|
|
owsAssertDebug(packIdHex == nil)
|
|
packIdHex = queryItem.value
|
|
case "pack_key":
|
|
owsAssertDebug(packKeyHex == nil)
|
|
packKeyHex = queryItem.value
|
|
default:
|
|
Logger.warn("Unknown query item in sticker pack url")
|
|
}
|
|
}
|
|
return StickerPackInfo.parse(packIdHex: packIdHex, packKeyHex: packKeyHex)
|
|
}
|
|
|
|
/// Returns whether the given URL is an `sgnl://` link-new-device URL.
|
|
private static func isSgnlLinkDeviceUrl(_ url: URL) -> DeviceProvisioningURL? {
|
|
return DeviceProvisioningURL(urlString: url.absoluteString)
|
|
}
|
|
|
|
// MARK: - Opening URLs
|
|
|
|
@MainActor
|
|
func openUrl(_ parsedUrl: ParsedUrl, in window: UIWindow) {
|
|
guard let rootViewController = window.rootViewController else {
|
|
owsFailDebug("Ignoring URL; no root view controller.")
|
|
return
|
|
}
|
|
if shouldDismiss(for: parsedUrl.openableUrl), rootViewController.presentedViewController != nil {
|
|
rootViewController.dismiss(animated: false, completion: {
|
|
self.openUrlAfterDismissing(parsedUrl.openableUrl, rootViewController: rootViewController)
|
|
})
|
|
} else {
|
|
openUrlAfterDismissing(parsedUrl.openableUrl, rootViewController: rootViewController)
|
|
}
|
|
}
|
|
|
|
private func shouldDismiss(for url: OpenableUrl) -> Bool {
|
|
switch url {
|
|
case .completeIDEALDonation: return false
|
|
case .groupInvite, .linkDevice, .phoneNumberLink, .signalProxy, .stickerPack, .usernameLink, .callLink, .quickRestore: return true
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func openUrlAfterDismissing(_ openableUrl: OpenableUrl, rootViewController: UIViewController) {
|
|
do throws(NotRegisteredError) {
|
|
try _openUrlAfterDismissing(openableUrl, rootViewController: rootViewController)
|
|
} catch {
|
|
Logger.warn("Ignoring url because we're not registered")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func _openUrlAfterDismissing(_ openableUrl: OpenableUrl, rootViewController: UIViewController) throws(NotRegisteredError) {
|
|
switch openableUrl {
|
|
case .phoneNumberLink(let url):
|
|
_ = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
|
|
SignalDotMePhoneNumberLink.openChat(url: url, fromViewController: rootViewController)
|
|
|
|
case .usernameLink(let link):
|
|
_ = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
|
|
Task {
|
|
guard
|
|
let (_, aci) = await UsernameQuerier().queryForUsernameLink(
|
|
link: link,
|
|
fromViewController: rootViewController,
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
SignalApp.shared.presentConversationForAddress(
|
|
SignalServiceAddress(aci),
|
|
animated: true,
|
|
)
|
|
}
|
|
|
|
case .stickerPack(let stickerPackInfo):
|
|
_ = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
|
|
let stickerPackViewController = StickerPackViewController(stickerPackInfo: stickerPackInfo)
|
|
stickerPackViewController.present(from: rootViewController, animated: false)
|
|
|
|
case .groupInvite(let url):
|
|
_ = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
|
|
GroupInviteLinksUI.openGroupInviteLink(url, fromViewController: rootViewController)
|
|
|
|
case .signalProxy(let url):
|
|
rootViewController.present(ProxyLinkSheetViewController(url: url)!, animated: true)
|
|
|
|
case .linkDevice:
|
|
let registeredState = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
|
|
guard registeredState.isPrimary else {
|
|
Logger.warn("Ignoring URL; not primary device.")
|
|
return
|
|
}
|
|
|
|
let linkDeviceWarningActionSheet = ActionSheetController(
|
|
message: OWSLocalizedString(
|
|
"LINKED_DEVICE_URL_OPENED_ACTION_SHEET_EXTERNAL_URL_MESSAGE",
|
|
comment: "Message for an action sheet telling users how to link a device, when trying to open an external device-linking URL.",
|
|
),
|
|
)
|
|
|
|
let showLinkedDevicesAction = ActionSheetAction(
|
|
title: OWSLocalizedString(
|
|
"LINKED_DEVICES_TITLE",
|
|
comment: "Menu item and navbar title for the device manager",
|
|
),
|
|
) { _ in
|
|
SignalApp.shared.showAppSettings(mode: .linkedDevices)
|
|
}
|
|
|
|
linkDeviceWarningActionSheet.addAction(showLinkedDevicesAction)
|
|
linkDeviceWarningActionSheet.addAction(.cancel)
|
|
rootViewController.presentActionSheet(linkDeviceWarningActionSheet)
|
|
|
|
case .quickRestore:
|
|
let registeredState = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
|
|
guard registeredState.isPrimary else {
|
|
Logger.warn("Ignoring URL; not primary device.")
|
|
return
|
|
}
|
|
|
|
let quickRestoreWarningActionSheet = ActionSheetController(
|
|
message: OWSLocalizedString(
|
|
"QUICK_RESTORE_URL_OPENED_ACTION_SHEET_EXTERNAL_URL_MESSAGE",
|
|
comment: "Message for an action sheet telling users how to use quick restore, when trying to open an external quick restore URL.",
|
|
),
|
|
)
|
|
|
|
let showCameraViewAction = ActionSheetAction(
|
|
title: CommonStrings.continueButton,
|
|
) { _ in
|
|
SignalApp.shared.showCameraCaptureView { navController in
|
|
let sheet = HeroSheetViewController(
|
|
hero: .image(UIImage(named: "phone-qr")!),
|
|
title: OWSLocalizedString(
|
|
"QUICK_RESTORE_URL_OPENED_ACTION_SHEET_EXTERNAL_URL_ACTION_TITLE",
|
|
comment: "Title for sheet with info about scanning a Quick Restore QR code",
|
|
),
|
|
body: OWSLocalizedString(
|
|
"QUICK_RESTORE_URL_OPENED_ACTION_SHEET_EXTERNAL_URL_ACTION_BODY",
|
|
comment: "Body for sheet with info about scanning a Quick Restore QR code",
|
|
),
|
|
primaryButton: .dismissing(title: CommonStrings.okButton),
|
|
)
|
|
navController.topViewController?.present(sheet, animated: true)
|
|
}
|
|
}
|
|
|
|
quickRestoreWarningActionSheet.addAction(showCameraViewAction)
|
|
quickRestoreWarningActionSheet.addAction(.cancel)
|
|
rootViewController.presentActionSheet(quickRestoreWarningActionSheet)
|
|
|
|
case .completeIDEALDonation(let donationType):
|
|
_ = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
|
|
Task { [appReadiness, databaseStorage] in
|
|
let handled = await DonationViewsUtil.attemptToContinueActiveIDEALDonation(
|
|
type: donationType,
|
|
databaseStorage: databaseStorage,
|
|
)
|
|
if handled {
|
|
Logger.info("[Donations] Completed iDEAL donation")
|
|
return
|
|
}
|
|
do {
|
|
try await DonationViewsUtil.restartAndCompleteInterruptedIDEALDonation(
|
|
type: donationType,
|
|
rootViewController: rootViewController,
|
|
databaseStorage: databaseStorage,
|
|
appReadiness: appReadiness,
|
|
)
|
|
Logger.info("[Donations] Completed iDEAL donation")
|
|
} catch Signal.DonationJobError.timeout {
|
|
// This is an expected error case for pending donations
|
|
} catch {
|
|
// Unexpected. Log a warning
|
|
Logger.warn("[Donations] Unexpected error encountered with iDEAL donation")
|
|
}
|
|
}
|
|
|
|
case .callLink(let callLink):
|
|
_ = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
|
|
GroupCallViewController.presentLobby(for: callLink)
|
|
}
|
|
}
|
|
}
|