diff --git a/Tusker/Screens/Onboarding/OnboardingViewController.swift b/Tusker/Screens/Onboarding/OnboardingViewController.swift index 7d39dd86..f3331181 100644 --- a/Tusker/Screens/Onboarding/OnboardingViewController.swift +++ b/Tusker/Screens/Onboarding/OnboardingViewController.swift @@ -9,12 +9,15 @@ import UIKit import AuthenticationServices import Pachyderm +import OSLog protocol OnboardingViewControllerDelegate { @MainActor func didFinishOnboarding(account: LocalData.UserAccountInfo) } +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "OnboardingViewController") + class OnboardingViewController: UINavigationController { var onboardingDelegate: OnboardingViewControllerDelegate? @@ -40,7 +43,78 @@ class OnboardingViewController: UINavigationController { } @MainActor - private func tryLoginTo(instanceURL: URL) async throws { + private func login(to instanceURL: URL) async { + let dimmingView = UIView() + dimmingView.translatesAutoresizingMaskIntoConstraints = false + dimmingView.backgroundColor = .black.withAlphaComponent(0.1) + + let blur = UIBlurEffect(style: .prominent) + let blurView = UIVisualEffectView(effect: blur) + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.layer.cornerRadius = 15 + blurView.layer.masksToBounds = true + + let spinner = UIActivityIndicatorView(style: .large) + spinner.translatesAutoresizingMaskIntoConstraints = false + spinner.startAnimating() + + let statusLabel = UILabel() + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusLabel.font = .preferredFont(forTextStyle: .headline) + statusLabel.numberOfLines = 0 + statusLabel.textAlignment = .center + + blurView.contentView.addSubview(spinner) + blurView.contentView.addSubview(statusLabel) + dimmingView.addSubview(blurView) + view.addSubview(dimmingView) + NSLayoutConstraint.activate([ + dimmingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dimmingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + dimmingView.topAnchor.constraint(equalTo: view.topAnchor), + dimmingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + blurView.widthAnchor.constraint(equalToConstant: 150), + blurView.heightAnchor.constraint(equalToConstant: 150), + blurView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + blurView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), + + spinner.centerXAnchor.constraint(equalTo: blurView.contentView.centerXAnchor), + spinner.bottomAnchor.constraint(equalTo: blurView.contentView.centerYAnchor, constant: -2), + statusLabel.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor), + statusLabel.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor), + statusLabel.topAnchor.constraint(equalTo: blurView.contentView.centerYAnchor, constant: 2), + ]) + + dimmingView.layer.opacity = 0 + blurView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { + dimmingView.layer.opacity = 1 + blurView.transform = .identity + } + + do { + try await tryLogin(to: instanceURL) { + statusLabel.text = $0 + } + } catch Error.cancelled { + // no-op, don't show an error message + } catch { + let message: String + if let error = error as? Error { + message = error.localizedDescription + } else { + message = error.localizedDescription + } + let alert = UIAlertController(title: "Error Logging In", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + self.present(alert, animated: true) + } + + dimmingView.removeFromSuperview() + } + + private func tryLogin(to instanceURL: URL, updateStatus: (String) -> Void) async throws { let mastodonController = MastodonController(instanceURL: instanceURL, transient: true) let clientID: String let clientSecret: String @@ -48,28 +122,24 @@ class OnboardingViewController: UINavigationController { clientID = clientInfo.id clientSecret = clientInfo.secret } else { + updateStatus("Registering App") do { (clientID, clientSecret) = try await mastodonController.registerApp() self.clientInfo = (instanceURL, clientID, clientSecret) - // m.s has problems with (I think) the read replicas not updating fast enough - // so give it some more time to propagate, and prevent invalid_client/etc. errors - if instanceURL.host == "mastodon.social" { - try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) - } + updateStatus("Reticulating Splines") + try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) } catch { throw Error.registeringApp(error) } } + updateStatus("Logging in") let authCode = try await getAuthorizationCode(instanceURL: instanceURL, clientID: clientID) - if instanceURL.host == "mastodon.social" { - try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) - } + updateStatus("Authorizing") let accessToken: String do { - accessToken = try await mastodonController.authorize(authorizationCode: authCode) - if instanceURL.host == "mastodon.social" { - try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) - } + accessToken = try await retrying("Getting access token") { + try await mastodonController.authorize(authorizationCode: authCode) + } } catch { throw Error.gettingAccessToken(error) } @@ -78,9 +148,12 @@ class OnboardingViewController: UINavigationController { let tempAccountInfo = LocalData.UserAccountInfo(tempInstanceURL: instanceURL, clientID: clientID, clientSecret: clientSecret, accessToken: accessToken) mastodonController.accountInfo = tempAccountInfo + updateStatus("Checking Credentials") let ownAccount: Account do { - ownAccount = try await mastodonController.getOwnAccount() + ownAccount = try await retrying("Getting own account") { + try await mastodonController.getOwnAccount() + } } catch { throw Error.gettingOwnAccount(error) } @@ -91,6 +164,19 @@ class OnboardingViewController: UINavigationController { self.onboardingDelegate?.didFinishOnboarding(account: accountInfo) } + private func retrying(_ label: StaticString, action: () async throws -> T) async throws -> T { + for attempt in 0..<4 { + do { + return try await action() + } catch { + let seconds = (pow(2, attempt) as NSDecimalNumber).uint64Value + logger.error("\(label, privacy: .public) failed, waiting \(seconds, privacy: .public)s before retrying. Reason: \(String(describing: error))") + try! await Task.sleep(nanoseconds: seconds * NSEC_PER_SEC) + } + } + return try await action() + } + @MainActor private func getAuthorizationCode(instanceURL: URL, clientID: String) async throws -> String { var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)! @@ -160,15 +246,8 @@ extension OnboardingViewController { extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate { func didSelectInstance(url instanceURL: URL) { Task { - do { - try await self.tryLoginTo(instanceURL: instanceURL) - } catch Error.cancelled { - // no-op, don't show an error message - } catch let error as Error { - let alert = UIAlertController(title: "Error Logging In", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - self.present(alert, animated: true) - } + await self.login(to: instanceURL) + instanceSelector.tableView.selectRow(at: nil, animated: true, scrollPosition: .none) } } }