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

import CoreServices
import Intents
import PureLayout
import SignalServiceKit
public import SignalUI
import UniformTypeIdentifiers

public class ShareViewController: OWSNavigationController, ShareViewDelegate, SAEFailedViewDelegate {

    enum ShareViewControllerError: Error {
        case obsoleteShare
        case screenLockEnabled
        case tooManyAttachments
        case nilInputItems
        case noInputItems
        case noConformingInputItem
        case nilAttachments
        case noAttachments
    }

    public var shareViewNavigationController: OWSNavigationController { self }

    private lazy var appReadiness = AppReadinessImpl()

    private var connectionTokens = [OWSChatConnection.ConnectionToken]()

    private var initialLoadViewController: SAELoadViewController?

    override open func loadView() {
        super.loadView()

        // This should be the first thing we do.
        let appContext = ShareAppExtensionContext(rootViewController: self)
        SetCurrentAppContext(appContext, isRunningTests: false)

        let debugLogger = DebugLogger.shared
        debugLogger.enableTTYLoggingIfNeeded()
        debugLogger.enableFileLogging(appContext: appContext, canLaunchInBackground: false)
        DebugLogger.registerLibsignal()

        Logger.info("")

        let initialLoadViewController = SAELoadViewController(
            delegate: self,
            shouldMimicRecipientPicker: self.extensionContext?.intent == nil,
        )
        self.setViewControllers([initialLoadViewController], animated: false)
        self.initialLoadViewController = initialLoadViewController
    }

    override public func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        if let initialLoadViewController = self.initialLoadViewController.take() {
            // Wait one run loop to ensure the loading indicator is visible if setUp
            // blocks the main thread.
            DispatchQueue.main.async {
                Task { try await self.setUp(initialLoadViewController: initialLoadViewController) }
            }
        }
    }

    private func setUp(initialLoadViewController: SAELoadViewController) async throws {
        let appContext = CurrentAppContext()

        let keychainStorage = KeychainStorageImpl(isUsingProductionService: TSConstants.isUsingProductionService)
        let databaseStorage: SDSDatabaseStorage
        do {
            databaseStorage = try SDSDatabaseStorage(
                appReadiness: appReadiness,
                databaseFileUrl: SDSDatabaseStorage.grdbDatabaseFileUrl,
                keychainStorage: keychainStorage,
            )
        } catch {
            self.showNotRegisteredView()
            return
        }
        databaseStorage.grdbStorage.setUpDatabasePathKVO()

        let databaseContinuation = await AppSetup()
            .start(
                appContext: appContext,
                databaseStorage: databaseStorage,
            )
            .migrateDatabaseSchema()
            .initGlobals(
                appContext: appContext,
                appReadiness: appReadiness,
                backupArchiveErrorPresenterFactory: NoOpBackupArchiveErrorPresenterFactory(),
                deviceBatteryLevelManager: nil,
                deviceSleepManager: nil,
                paymentsEvents: PaymentsEventsAppExtension(),
                mobileCoinHelper: MobileCoinHelperMinimal(),
                callMessageHandler: NoopCallMessageHandler(),
                currentCallProvider: CurrentCallNoOpProvider(),
                notificationPresenter: NoopNotificationPresenterImpl(),
            )

        // Configure the rest of the globals before preparing the database.
        SUIEnvironment.shared.setUp(
            appReadiness: appReadiness,
            authCredentialManager: databaseContinuation.authCredentialManager,
        )

        let finalContinuation = await databaseContinuation.migrateDatabaseData()
        finalContinuation.runLaunchTasksIfNeededAndReloadCaches()
        switch finalContinuation.setUpLocalIdentifiers(
            willResumeInProgressRegistration: false,
            canInitiateRegistration: false,
        ) {
        case .corruptRegistrationState:
            self.showNotRegisteredView()
            return
        case nil:
            self.setAppIsReady()
        }

        var didDisplaceInitialLoadViewController = false

        if ScreenLock.shared.isScreenLockEnabled() {
            let didUnlock = await withCheckedContinuation { continuation in
                let viewController = SAEScreenLockViewController { didUnlock in
                    continuation.resume(returning: didUnlock)
                }
                self.setViewControllers([viewController], animated: false)
            }
            guard didUnlock else {
                self.shareViewWasCancelled()
                return
            }
            // If we show the Screen Lock UI, that'll displace the loading view
            // controller or prevent it from being shown.
            didDisplaceInitialLoadViewController = true
        }

        // Prepare the attachments.

        let typedItemProviders: [TypedItemProvider]
        do {
            typedItemProviders = try buildTypedItemProviders()
        } catch {
            self.presentAttachmentError(error)
            return
        }

        // We need the unidentified connection for bulk identity key lookups.
        let chatConnectionManager = DependenciesBridge.shared.chatConnectionManager
        self.connectionTokens.append(chatConnectionManager.requestUnidentifiedConnection())

        let attachmentLimits = OutgoingAttachmentLimits.currentLimits()

        let conversationPicker: SharingThreadPickerViewController
        conversationPicker = SharingThreadPickerViewController(
            areAttachmentStoriesCompatPrecheck: typedItemProviders.allSatisfy { $0.isStoriesCompatible },
            attachmentLimits: attachmentLimits,
            shareViewDelegate: self,
        )

        let preSelectedThread = self.fetchPreSelectedThread()

        let loadViewControllerToDisplay: SAELoadViewController?
        let loadViewControllerForProgress: SAELoadViewController?

        // If we have a pre-selected thread, we wait to show the approval view
        // until the attachments have been built. Otherwise, we'll present it
        // immediately and tell it what attachments we're sharing once we've
        // finished building them.
        if preSelectedThread == nil {
            self.setViewControllers([conversationPicker], animated: false)
            // We show a progress spinner on the recipient picker.
            loadViewControllerToDisplay = nil
            loadViewControllerForProgress = nil
        } else if didDisplaceInitialLoadViewController {
            // We hit this branch when isScreenLockEnabled() == true. In this case, we
            // need a new instance because the initial one has already been
            // shown/dismissed.
            loadViewControllerToDisplay = SAELoadViewController(delegate: self)
            loadViewControllerForProgress = loadViewControllerToDisplay
        } else {
            // We don't need to show anything (it'll be shown by the block at the
            // beginning of this Task), but we do want to hook up progress reporting.
            loadViewControllerToDisplay = nil
            loadViewControllerForProgress = initialLoadViewController
        }

        let typedItems: [TypedItem]
        do {
            // If buildAndValidateAttachments takes longer than 200ms, we want to show
            // the new load view. If it takes less than 200ms, we'll exit out of this
            // `do` block, that will cancel the `async let`, and then we'll leave the
            // primary view controller alone as a result.
            async let _ = { @MainActor () async throws -> Void in
                guard let loadViewControllerToDisplay else {
                    return
                }
                try await Task.sleep(nanoseconds: 0.2.clampedNanoseconds)
                // Check for cancellation on the main thread to ensure mutual exclusion
                // with the the code outside of this do block.
                try Task.checkCancellation()
                self.setViewControllers([loadViewControllerToDisplay], animated: false)
            }()
            typedItems = try await buildAndValidateAttachments(
                for: typedItemProviders,
                attachmentLimits: attachmentLimits,
                setProgress: { loadViewControllerForProgress?.progress = $0 },
            )
        } catch {
            self.presentAttachmentError(error)
            return
        }

        Logger.info("Setting picker attachments: \(typedItems.count)")
        conversationPicker.typedItems = typedItems

        if let preSelectedThread {
            let approvalViewController = try conversationPicker.buildApprovalViewController(for: preSelectedThread)
            self.setViewControllers([approvalViewController], animated: false)

            // If you're sharing to a specific thread, the picker view controller isn't
            // added to the view hierarchy, but it's the "brains" of the sending
            // operation and must not be deallocated. Tie its lifetime to the lifetime
            // of the view controller that's visible.
            ObjectRetainer.retainObject(conversationPicker, forLifetimeOf: approvalViewController)
        }

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(applicationDidEnterBackground),
            name: .OWSApplicationDidEnterBackground,
            object: nil,
        )

        Logger.info("completed.")
    }

    deinit {
        Logger.info("deinit")
    }

    @objc
    private func applicationDidEnterBackground() {
        AssertIsOnMainThread()

        Logger.info("")

        if ScreenLock.shared.isScreenLockEnabled() {
            Logger.info("dismissing.")
            dismissAndCompleteExtension(error: ShareViewControllerError.screenLockEnabled)
        }
    }

    private func setAppIsReady() {
        AssertIsOnMainThread()
        owsPrecondition(!appReadiness.isAppReady)

        // Note that this does much more than set a flag; it will also run all deferred blocks.
        appReadiness.setAppIsReady()

        let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aci
        Logger.info("localAci: \(localAci?.logString ?? "<none>")")

        let appVersion = AppVersionImpl.shared
        appVersion.dumpToLog()
        appVersion.updateFirstVersionIfNeeded()
        appVersion.saeLaunchDidComplete()

        Logger.info("")
    }

    // MARK: Error Views

    private func showNotRegisteredView() {
        AssertIsOnMainThread()

        let failureTitle = OWSLocalizedString(
            "SHARE_EXTENSION_NOT_REGISTERED_TITLE",
            comment: "Title indicating that the share extension cannot be used until the user has registered in the main app.",
        )
        let failureMessage = OWSLocalizedString(
            "SHARE_EXTENSION_NOT_REGISTERED_MESSAGE",
            comment: "Message indicating that the share extension cannot be used until the user has registered in the main app.",
        )
        showErrorView(title: failureTitle, message: failureMessage)
    }

    private func showErrorView(title: String, message: String) {
        AssertIsOnMainThread()

        let viewController = SAEFailedViewController(delegate: self, title: title, message: message)

        self.setViewControllers([viewController], animated: false)
    }

    // MARK: ShareViewDelegate, SAEFailedViewDelegate

    public func shareViewWillSend() {
        let chatConnectionManager = DependenciesBridge.shared.chatConnectionManager
        self.connectionTokens.append(chatConnectionManager.requestIdentifiedConnection())
    }

    public func shareViewWasCompleted() {
        Logger.info("")
        dismissAndCompleteExtension(error: nil)
    }

    public func shareViewWasCancelled() {
        Logger.info("")
        dismissAndCompleteExtension(error: ShareViewControllerError.obsoleteShare)
    }

    public func shareViewFailed(error: Error) {
        owsFailDebug("Error: \(error)")
        dismissAndCompleteExtension(error: error)
    }

    private func dismissAndCompleteExtension(error: Error?) {
        AssertIsOnMainThread()

        let extensionContext = self.extensionContext
        if let error {
            extensionContext?.cancelRequest(withError: error)
        } else {
            extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
        }

        // Share extensions reside in a process that may be reused between usages.
        // That isn't safe; the codebase is full of statics (e.g. singletons) which
        // we can't easily clean up.
        Logger.info("ExitShareExtension")
        Logger.flush()
        exit(0)
    }

    // MARK: Helpers

    private func fetchPreSelectedThread() -> TSThread? {
        let hasIntent = self.extensionContext?.intent != nil
        Logger.info("hasIntent? \(hasIntent)")
        if let threadUniqueId = (self.extensionContext?.intent as? INSendMessageIntent)?.conversationIdentifier {
            let result = SSKEnvironment.shared.databaseStorageRef.read { TSThread.fetchViaCache(uniqueId: threadUniqueId, transaction: $0) }
            Logger.info("hasThread? \(result != nil)")
            return result
        } else {
            return nil
        }
    }

    private func buildTypedItemProviders() throws -> [TypedItemProvider] {
        guard let inputItems = self.extensionContext?.inputItems as? [NSExtensionItem] else {
            throw ShareViewControllerError.nilInputItems
        }
#if DEBUG
        for (inputItemIndex, inputItem) in inputItems.enumerated() {
            Logger.debug("- inputItems[\(inputItemIndex)]")
            for (itemProvidersIndex, itemProviders) in inputItem.attachments!.enumerated() {
                Logger.debug("  - itemProviders[\(itemProvidersIndex)]")
                for typeIdentifier in itemProviders.registeredTypeIdentifiers {
                    Logger.debug("    - \(typeIdentifier)")
                }
            }
        }
#endif
        let inputItem = try Self.selectExtensionItem(inputItems)
        guard let itemProviders = inputItem.attachments else {
            throw ShareViewControllerError.nilAttachments
        }
        guard !itemProviders.isEmpty else {
            throw ShareViewControllerError.noAttachments
        }

        let candidates = try itemProviders.map(TypedItemProvider.make(for:))

        // URL shares can come in with text preview and favicon attachments so we ignore other attachments with a URL
        if let webUrlCandidate = candidates.first(where: { $0.isWebUrl }) {
            return [webUrlCandidate]
        }

        // only 1 attachment is supported unless it's visual media so select just the first or just the visual media elements with a preference for visual media
        let visualMediaCandidates = candidates.filter { $0.isVisualMedia }
        return visualMediaCandidates.isEmpty ? Array(candidates.prefix(1)) : visualMediaCandidates
    }

    private func buildAndValidateAttachments(
        for typedItemProviders: [TypedItemProvider],
        attachmentLimits: OutgoingAttachmentLimits,
        setProgress: @MainActor (Progress) -> Void,
    ) async throws -> [TypedItem] {
        let progress = Progress(totalUnitCount: Int64(typedItemProviders.count))

        let itemsAndProgresses = typedItemProviders.map {
            let itemProgress = Progress(totalUnitCount: 10_000)
            progress.addChild(itemProgress, withPendingUnitCount: 1)
            return ($0, itemProgress)
        }

        setProgress(progress)

        let typedItems = try await self.buildAttachments(for: itemsAndProgresses, attachmentLimits: attachmentLimits)
        try Task.checkCancellation()

        // Make sure the user is not trying to share more than our attachment limit.
        guard typedItems.count <= SignalAttachment.maxAttachmentsAllowed else {
            throw ShareViewControllerError.tooManyAttachments
        }

        return typedItems
    }

    private func presentAttachmentError(_ error: any Error) {
        switch error {
        case ShareViewControllerError.tooManyAttachments:
            let format = OWSLocalizedString(
                "IMAGE_PICKER_CAN_SELECT_NO_MORE_TOAST_FORMAT",
                comment: "Momentarily shown to the user when attempting to select more images than is allowed. Embeds {{max number of items}} that can be shared.",
            )

            let alertTitle = String.nonPluralLocalizedStringWithFormat(format, OWSFormat.formatInt(SignalAttachment.maxAttachmentsAllowed))

            OWSActionSheets.showActionSheet(
                title: alertTitle,
                buttonTitle: CommonStrings.cancelButton,
            ) { _ in
                self.shareViewWasCancelled()
            }
        default:
            Logger.warn("building attachment failed with error: \(error)")

            let alertTitle = OWSLocalizedString(
                "SHARE_EXTENSION_UNABLE_TO_BUILD_ATTACHMENT_ALERT_TITLE",
                comment: "Shown when trying to share content to a Signal user for the share extension. Followed by failure details.",
            )
            OWSActionSheets.showActionSheet(
                title: alertTitle,
                message: error.userErrorDescription,
                buttonTitle: CommonStrings.cancelButton,
            ) { _ in
                self.shareViewWasCancelled()
            }
        }
    }

    private static func selectExtensionItem(_ extensionItems: [NSExtensionItem]) throws -> NSExtensionItem {
        if extensionItems.isEmpty {
            throw ShareViewControllerError.noInputItems
        }
        if extensionItems.count == 1 {
            return extensionItems.first!
        }

        // Handle safari sharing images and PDFs as two separate items one with the object to share and the other as the URL of the data.
        for extensionItem in extensionItems {
            for attachment in extensionItem.attachments ?? [] {
                if
                    attachment.hasItemConformingToTypeIdentifier(UTType.data.identifier)
                    || attachment.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier)
                    || attachment.hasItemConformingToTypeIdentifier("com.apple.pkpass")
                {
                    return extensionItem
                }
            }
        }
        throw ShareViewControllerError.noConformingInputItem
    }

    private nonisolated func buildAttachments(
        for itemsAndProgresses: [(TypedItemProvider, Progress)],
        attachmentLimits: OutgoingAttachmentLimits,
    ) async throws -> [TypedItem] {
        // FIXME: does not use a task group because SignalAttachment likes to load things into RAM and resize them; doing this in parallel can exhaust available RAM
        var result: [TypedItem] = []
        for (typedItemProvider, progress) in itemsAndProgresses {
            result.append(try await typedItemProvider.buildAttachment(attachmentLimits: attachmentLimits, progress: progress))
        }
        return result
    }

    override public func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        // If we're disappearing because we presented something else (e.g., image
        // editing tools), don't cancel the share extension.
        guard self.presentedViewController == nil else {
            return
        }

        shareViewWasCancelled()
    }
}