// // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalServiceKit import SignalUI // The initial presentation is intended to be indistinguishable from the Launch Screen. // After a delay we present some "loading" UI so the user doesn't think the app is frozen. class LoadingViewController: UIViewController { private var logoView: UIImageView! private var topLabel: UILabel! private var bottomLabel: UILabel! private var progressView = UIProgressView() private lazy var percentCompleteLabel = UILabel() private lazy var unitCountLabel = UILabel() private lazy var cancelButton = OWSButton() private let labelStack = UIStackView() private var topLabelTimer: Timer? private var bottomLabelTimer: Timer? private var cancelButtonTimer: Timer? override func loadView() { self.view = UIView() view.backgroundColor = Theme.launchScreenBackgroundColor self.logoView = UIImageView(image: #imageLiteral(resourceName: "signal-logo-128-launch-screen")) view.addSubview(logoView) logoView.autoCenterInSuperview() logoView.autoSetDimensions(to: CGSize(square: 128)) self.topLabel = buildLabel() topLabel.isHiddenInStackView = true topLabel.font = UIFont.dynamicTypeTitle2 topLabel.text = OWSLocalizedString("DATABASE_VIEW_OVERLAY_TITLE", comment: "Title shown while the app is updating its database.") labelStack.addArrangedSubview(topLabel) self.bottomLabel = buildLabel() bottomLabel.isHiddenInStackView = true bottomLabel.font = UIFont.dynamicTypeBody bottomLabel.text = OWSLocalizedString( "DATABASE_VIEW_OVERLAY_SUBTITLE", comment: "Subtitle shown while the app is updating its database.", ) + "\n" + OWSLocalizedString( "LOADING_VIEW_CONTROLLER_DONT_CLOSE_APP", comment: "Shown to users while the app is loading, asking them not to close the app.", ) bottomLabel.textAlignment = .center labelStack.addArrangedSubview(bottomLabel) labelStack.setCustomSpacing(20, after: bottomLabel) progressView.setProgress(0.1, animated: false) progressView.isHiddenInStackView = true labelStack.addArrangedSubview(progressView) labelStack.setCustomSpacing(16, after: progressView) progressView.autoPinWidthToSuperview(withMargin: 20, relation: .lessThanOrEqual) progressView.autoSetDimension(.width, toSize: 330).priority = .defaultLow percentCompleteLabel.isHiddenInStackView = true percentCompleteLabel.font = { let metrics = UIFontMetrics(forTextStyle: .body) let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) let font = UIFont.monospacedDigitSystemFont(ofSize: desc.pointSize, weight: .regular) return metrics.scaledFont(for: font) }() percentCompleteLabel.textColor = .Signal.secondaryLabel labelStack.addArrangedSubview(percentCompleteLabel) labelStack.setCustomSpacing(6, after: percentCompleteLabel) unitCountLabel.isHiddenInStackView = true unitCountLabel.font = .dynamicTypeBody.monospaced() unitCountLabel.textColor = .Signal.secondaryLabel labelStack.addArrangedSubview(unitCountLabel) cancelButton.isHiddenInStackView = true cancelButton.isEnabled = false cancelButton.titleLabel?.font = .dynamicTypeBody.monospaced() cancelButton.backgroundColor = .clear cancelButton.setTitle(CommonStrings.cancelButton, for: .normal) cancelButton.setTitleColor(.Signal.ultramarine, for: .normal) labelStack.addArrangedSubview(cancelButton) cancelButton.block = { [weak self] in self?.cancellableTask?.cancel() self?.setCancellableTask(nil) } labelStack.axis = .vertical labelStack.alignment = .center labelStack.spacing = 8 view.addSubview(labelStack) labelStack.autoPinEdge(.top, to: .bottom, of: logoView, withOffset: 40) labelStack.autoPinLeadingToSuperviewMargin() labelStack.autoPinTrailingToSuperviewMargin() labelStack.setCompressionResistanceHigh() labelStack.setContentHuggingHigh() NotificationCenter.default.addObserver( self, selector: #selector(didBecomeActive), name: .OWSApplicationDidBecomeActive, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(didEnterBackground), name: .OWSApplicationDidEnterBackground, object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(themeDidChange), name: .themeDidChange, object: nil, ) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // We only show the "loading" UI if it's a slow launch. Otherwise this ViewController // should be indistinguishable from the launch screen. let kTopLabelThreshold: TimeInterval = 5 topLabelTimer = Timer.scheduledTimer(withTimeInterval: kTopLabelThreshold, repeats: false) { [weak self] _ in self?.showTopLabel() } let kBottomLabelThreshold: TimeInterval = 10 bottomLabelTimer = Timer.scheduledTimer(withTimeInterval: kBottomLabelThreshold, repeats: false) { [weak self] _ in self?.showBottomLabelAnimated() } let kCancelButtonThreshold: TimeInterval = 60 cancelButtonTimer?.invalidate() self.canShowCancelButton = false self.updateCancelButton() cancelButtonTimer = Timer.scheduledTimer(withTimeInterval: kCancelButtonThreshold, repeats: false) { [weak self] _ in self?.canShowCancelButton = true self?.updateCancelButton() } } // UIStackView removes hidden subviews from the layout. // UIStackView considers views with a sufficiently low // alpha to be "hidden". This can cause layout to glitch // briefly when returning from background. Therefore we // use a "min" alpha value when fading in labels that is // high enough to avoid this UIStackView behavior. private let kMinAlpha: CGFloat = 0.1 private func showBottomLabelAnimated() { bottomLabel.layer.removeAllAnimations() bottomLabel.isHiddenInStackView = false bottomLabel.alpha = kMinAlpha UIView.animate(withDuration: 0.3) { self.bottomLabel.alpha = 1 self.progress.map(self.updateProgress(_:)) } } private func showTopLabel() { topLabel.layer.removeAllAnimations() topLabel.isHiddenInStackView = false topLabel.alpha = 0.2 UIView.animate(withDuration: 0.9, delay: 0, options: [.autoreverse, .repeat, .curveEaseInOut], animations: { self.topLabel.alpha = 1.0 }, completion: nil) } private func showBottomLabel() { bottomLabel.layer.removeAllAnimations() bottomLabel.isHiddenInStackView = false } // MARK: - @objc private func didBecomeActive() { AssertIsOnMainThread() guard viewHasEnteredBackground else { // If the app is returning from background, skip any // animations and show the top and bottom labels. return } topLabelTimer?.invalidate() topLabelTimer = nil bottomLabelTimer?.invalidate() bottomLabelTimer = nil showTopLabel() showBottomLabel() labelStack.layoutSubviews() view.layoutSubviews() } private var viewHasEnteredBackground = false @objc private func didEnterBackground() { AssertIsOnMainThread() viewHasEnteredBackground = true } @objc private func themeDidChange() { view.backgroundColor = Theme.launchScreenBackgroundColor } private var progress: OWSProgress? func updateProgress(_ progress: OWSProgress) { self.progress = progress let percentComplete = progress.percentComplete let unitCountToComplete = progress.totalUnitCount let unitCountCompleted = Int(Float(unitCountToComplete) * progress.percentComplete) progressView.setProgress(percentComplete, animated: true) percentCompleteLabel.text = String.nonPluralLocalizedStringWithFormat( OWSLocalizedString( "LINK_NEW_DEVICE_SYNC_PROGRESS_PERCENT", comment: "On a progress modal indicating the percent complete the sync process is. Embeds {{ formatted percentage }}", ), percentComplete.formatted(.owsPercent()), ) unitCountLabel.text = "\(unitCountCompleted.formatted(.number)) / \(unitCountToComplete.formatted(.number))" if percentComplete > 0 { percentCompleteLabel.alpha = bottomLabel.alpha progressView.alpha = bottomLabel.alpha } else { percentCompleteLabel.alpha = 0 progressView.alpha = 0 } if unitCountToComplete > 0, percentComplete > 0 { unitCountLabel.alpha = bottomLabel.alpha } else { unitCountLabel.alpha = 0 } } private var cancellableTask: Task? private var canShowCancelButton = false // Sets the running task that is running and which is // cancellable (will display a cancel button). // Typically this task's work is represented in `updateProgress` // if that has been called; its ok to set this without // setting progress, however. func setCancellableTask(_ task: Task?) { self.cancellableTask = task updateCancelButton() } private func updateCancelButton() { if cancellableTask != nil, canShowCancelButton { cancelButton.isHiddenInStackView = false cancelButton.isEnabled = true } else { cancelButton.isHiddenInStackView = true cancelButton.isEnabled = false } } // MARK: Orientation override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return UIDevice.current.isIPad ? .all : .portrait } private func buildLabel() -> UILabel { let label = UILabel() label.textColor = .Signal.label label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping return label } } #if DEBUG @available(iOS 17, *) #Preview { let viewController = LoadingViewController() let progressSink = OWSProgress.createSink { progress in await MainActor.run { viewController.updateProgress(progress) } } let task = Task { let source = await progressSink.addSource(withLabel: "count", unitCount: 100) while source.completedUnitCount < 100 { do { try Task.checkCancellation() } catch { source.incrementCompletedUnitCount(by: 100) return } try? await Task.sleep(nanoseconds: 100 * NSEC_PER_SEC) source.incrementCompletedUnitCount(by: UInt64.random(in: 2...8)) } } viewController.setCancellableTask(task) return viewController } #endif