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()
}
}