Types that fit into RAM may be sent in via the Swift APIs as bplists instead of as their proper types. This appears to be an undocumented behavioral difference between the Swift APIs and the older Objective-C APIs we were using before. This change should now generically try to handle anything that might be small enough to fit in RAM as potentially a NSKeyedArchiver bplist out in the event that attempting to load the type directly fails.
938 lines
40 KiB
Swift
938 lines
40 KiB
Swift
//
|
|
// Copyright 2017 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import CoreServices
|
|
import Intents
|
|
import PureLayout
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
public class ShareViewController: UIViewController, ShareViewDelegate, SAEFailedViewDelegate {
|
|
|
|
enum ShareViewControllerError: Error {
|
|
case unsupportedMedia
|
|
case notRegistered
|
|
case obsoleteShare
|
|
case screenLockEnabled
|
|
case tooManyAttachments
|
|
case nilInputItems
|
|
case noInputItems
|
|
case noConformingInputItem
|
|
case nilAttachments
|
|
case noAttachments
|
|
case cannotLoadUIImageObject
|
|
case loadUIImageObjectFailed
|
|
case uiImageMissingOrCorruptImageData
|
|
case cannotLoadURLObject
|
|
case loadURLObjectFailed
|
|
case cannotLoadStringObject
|
|
case loadStringObjectFailed
|
|
case loadDataRepresentationFailed
|
|
case loadInPlaceFileRepresentationFailed
|
|
case nonFileUrl
|
|
case fileUrlWasBplist
|
|
}
|
|
|
|
private var hasInitialRootViewController = false
|
|
private var isReadyForAppExtensions = false
|
|
|
|
private var progressPoller: ProgressPoller?
|
|
lazy var loadViewController = SAELoadViewController(delegate: self)
|
|
|
|
public var shareViewNavigationController: OWSNavigationController?
|
|
private var loadTask: Task<Void, any Error>?
|
|
|
|
override open func loadView() {
|
|
super.loadView()
|
|
|
|
// This should be the first thing we do.
|
|
let appContext = ShareAppExtensionContext(rootViewController: self)
|
|
SetCurrentAppContext(appContext, false)
|
|
|
|
let debugLogger = DebugLogger.shared()
|
|
debugLogger.enableTTYLoggingIfNeeded()
|
|
debugLogger.setUpFileLoggingIfNeeded(appContext: appContext, canLaunchInBackground: false)
|
|
|
|
Logger.info("")
|
|
|
|
Cryptography.seedRandom()
|
|
|
|
// We don't need to use DeviceSleepManager in the SAE.
|
|
|
|
// We don't need to use applySignalAppearence in the SAE.
|
|
|
|
if appContext.isRunningTests {
|
|
// TODO: Do we need to implement isRunningTests in the SAE context?
|
|
return
|
|
}
|
|
|
|
let keychainStorage = KeychainStorageImpl(isUsingProductionService: TSConstants.isUsingProductionService)
|
|
let databaseStorage: SDSDatabaseStorage
|
|
do {
|
|
databaseStorage = try SDSDatabaseStorage(
|
|
databaseFileUrl: SDSDatabaseStorage.grdbDatabaseFileUrl,
|
|
keychainStorage: keychainStorage
|
|
)
|
|
} catch {
|
|
self.showNotRegisteredView()
|
|
return
|
|
}
|
|
|
|
// We shouldn't set up our environment until after we've consulted isReadyForAppExtensions.
|
|
let databaseContinuation = AppSetup().start(
|
|
appContext: appContext,
|
|
databaseStorage: databaseStorage,
|
|
paymentsEvents: PaymentsEventsAppExtension(),
|
|
mobileCoinHelper: MobileCoinHelperMinimal(),
|
|
callMessageHandler: NoopCallMessageHandler(),
|
|
currentCallThreadProvider: CurrentCallNoOpThreadProvider(),
|
|
notificationPresenter: NoopNotificationPresenterImpl()
|
|
)
|
|
|
|
// Configure the rest of the globals before preparing the database.
|
|
SUIEnvironment.shared.setup()
|
|
|
|
databaseContinuation.prepareDatabase().done(on: DispatchQueue.main) { finalContinuation in
|
|
switch finalContinuation.finish(willResumeInProgressRegistration: false) {
|
|
case .corruptRegistrationState:
|
|
self.showNotRegisteredView()
|
|
case nil:
|
|
self.setAppIsReady()
|
|
}
|
|
}
|
|
|
|
let shareViewNavigationController = OWSNavigationController()
|
|
shareViewNavigationController.presentationController?.delegate = self
|
|
shareViewNavigationController.delegate = self
|
|
self.shareViewNavigationController = shareViewNavigationController
|
|
|
|
// Don't display load screen immediately, in hopes that we can avoid it altogether.
|
|
Guarantee.after(seconds: 0.8).done { [weak self] in
|
|
AssertIsOnMainThread()
|
|
|
|
guard let strongSelf = self else { return }
|
|
guard strongSelf.presentedViewController == nil else {
|
|
Logger.debug("setup completed quickly, no need to present load view controller.")
|
|
return
|
|
}
|
|
|
|
Logger.debug("setup is slow - showing loading screen")
|
|
strongSelf.showPrimaryViewController(strongSelf.loadViewController)
|
|
}
|
|
|
|
// We don't need to use "screen protection" in the SAE.
|
|
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(registrationStateDidChange),
|
|
name: .registrationStateDidChange,
|
|
object: nil)
|
|
NotificationCenter.default.addObserver(self,
|
|
selector: #selector(owsApplicationWillEnterForeground),
|
|
name: .OWSApplicationWillEnterForeground,
|
|
object: nil)
|
|
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(animated: false, error: ShareViewControllerError.screenLockEnabled)
|
|
}
|
|
}
|
|
|
|
private func activate() {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.debug("")
|
|
|
|
// We don't need to use "screen protection" in the SAE.
|
|
|
|
ensureRootViewController()
|
|
|
|
// Always check prekeys after app launches, and sometimes check on app activation.
|
|
self.databaseStorage.read { tx in
|
|
if DependenciesBridge.shared.tsAccountManager.registrationState(tx: tx.asV2Read).isRegistered {
|
|
DependenciesBridge.shared.preKeyManager.checkPreKeysIfNecessary(tx: tx.asV2Read)
|
|
}
|
|
}
|
|
|
|
// We don't need to use RTCInitializeSSL() in the SAE.
|
|
|
|
if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered {
|
|
Logger.info("running post launch block for registered user: \(String(describing: DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress))")
|
|
} else {
|
|
Logger.info("running post launch block for unregistered user.")
|
|
|
|
// We don't need to update the app icon badge number in the SAE.
|
|
|
|
// We don't need to prod the ChatConnectionManager in the SAE.
|
|
}
|
|
|
|
if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard self != nil else { return }
|
|
Logger.info("running post launch block for registered user: \(String(describing: DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress))")
|
|
|
|
// We don't need to use the ChatConnectionManager in the SAE.
|
|
|
|
// TODO: Re-enable when system contact fetching uses less memory.
|
|
// self.contactsManager.fetchSystemContactsOnceIfAlreadyAuthorized()
|
|
|
|
// We don't need to fetch messages in the SAE.
|
|
|
|
// We don't need to use OWSSyncPushTokensJob in the SAE.
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setAppIsReady() {
|
|
Logger.debug("")
|
|
AssertIsOnMainThread()
|
|
owsAssert(!AppReadiness.isAppReady)
|
|
|
|
// We don't need to use LaunchJobs in the SAE.
|
|
|
|
// Note that this does much more than set a flag; it will also run all deferred blocks.
|
|
AppReadiness.setAppIsReady()
|
|
|
|
if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered {
|
|
Logger.info("localAddress: \(String(describing: DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress))")
|
|
|
|
// We don't need to use messageFetcherJob in the SAE.
|
|
|
|
// We don't need to use SyncPushTokensJob in the SAE.
|
|
}
|
|
|
|
// We don't need to use DeviceSleepManager in the SAE.
|
|
|
|
AppVersionImpl.shared.saeLaunchDidComplete()
|
|
|
|
ensureRootViewController()
|
|
|
|
// We don't need to fetch the local profile in the SAE
|
|
}
|
|
|
|
@objc
|
|
private func registrationStateDidChange() {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.debug("")
|
|
|
|
if DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered {
|
|
Logger.info("localAddress: \(String(describing: DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress))")
|
|
|
|
// We don't need to use ExperienceUpgradeFinder in the SAE.
|
|
|
|
// We don't need to use OWSDisappearingMessagesJob in the SAE.
|
|
}
|
|
}
|
|
|
|
private func ensureRootViewController() {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.debug("")
|
|
|
|
guard AppReadiness.isAppReady else {
|
|
return
|
|
}
|
|
guard !hasInitialRootViewController else {
|
|
return
|
|
}
|
|
hasInitialRootViewController = true
|
|
|
|
Logger.info("Presenting initial root view controller")
|
|
|
|
if ScreenLock.shared.isScreenLockEnabled() {
|
|
presentScreenLock()
|
|
} else {
|
|
presentContentView()
|
|
}
|
|
}
|
|
|
|
private func presentContentView() {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.debug("")
|
|
|
|
Logger.info("Presenting content view")
|
|
|
|
guard DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
|
|
showNotRegisteredView()
|
|
return
|
|
}
|
|
|
|
let localProfileExists = databaseStorage.read { transaction in
|
|
return self.profileManager.localProfileExists(with: transaction)
|
|
}
|
|
guard localProfileExists else {
|
|
// This is a rare edge case, but we want to ensure that the user
|
|
// has already saved their local profile key in the main app.
|
|
showNotReadyView()
|
|
return
|
|
}
|
|
|
|
buildAttachmentsAndPresentConversationPicker()
|
|
// We don't use the AppUpdateNag in the SAE.
|
|
}
|
|
|
|
// MARK: Error Views
|
|
|
|
private func showNotReadyView() {
|
|
AssertIsOnMainThread()
|
|
|
|
let failureTitle = OWSLocalizedString("SHARE_EXTENSION_NOT_YET_MIGRATED_TITLE",
|
|
comment: "Title indicating that the share extension cannot be used until the main app has been launched at least once.")
|
|
let failureMessage = OWSLocalizedString("SHARE_EXTENSION_NOT_YET_MIGRATED_MESSAGE",
|
|
comment: "Message indicating that the share extension cannot be used until the main app has been launched at least once.")
|
|
showErrorView(title: failureTitle, message: failureMessage)
|
|
}
|
|
|
|
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)
|
|
|
|
let navigationController = UINavigationController()
|
|
navigationController.presentationController?.delegate = self
|
|
navigationController.setViewControllers([viewController], animated: false)
|
|
if self.presentedViewController == nil {
|
|
Logger.debug("presenting modally: \(viewController)")
|
|
self.present(navigationController, animated: true)
|
|
} else {
|
|
owsFailDebug("modal already presented. swapping modal content for: \(viewController)")
|
|
assert(self.presentedViewController == navigationController)
|
|
}
|
|
}
|
|
|
|
// MARK: View Lifecycle
|
|
|
|
override open func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
Logger.debug("")
|
|
|
|
if isReadyForAppExtensions {
|
|
AppReadiness.runNowOrWhenAppDidBecomeReadySync { [weak self] in
|
|
AssertIsOnMainThread()
|
|
self?.activate()
|
|
}
|
|
}
|
|
}
|
|
|
|
override open func viewWillAppear(_ animated: Bool) {
|
|
Logger.debug("")
|
|
|
|
super.viewWillAppear(animated)
|
|
}
|
|
|
|
override open func viewDidAppear(_ animated: Bool) {
|
|
Logger.debug("")
|
|
|
|
super.viewDidAppear(animated)
|
|
}
|
|
|
|
override open func viewWillDisappear(_ animated: Bool) {
|
|
Logger.debug("")
|
|
|
|
super.viewWillDisappear(animated)
|
|
loadTask?.cancel()
|
|
loadTask = nil
|
|
}
|
|
|
|
@objc
|
|
private func owsApplicationWillEnterForeground() throws {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.debug("")
|
|
|
|
// If a user unregisters in the main app, the SAE should shut down
|
|
// immediately.
|
|
guard !DependenciesBridge.shared.tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
|
|
// If user is registered, do nothing.
|
|
return
|
|
}
|
|
guard let shareViewNavigationController = shareViewNavigationController else {
|
|
owsFailDebug("Missing shareViewNavigationController")
|
|
return
|
|
}
|
|
guard let firstViewController = shareViewNavigationController.viewControllers.first else {
|
|
// If no view has been presented yet, do nothing.
|
|
return
|
|
}
|
|
if firstViewController is SAEFailedViewController {
|
|
// If root view is an error view, do nothing.
|
|
return
|
|
}
|
|
throw ShareViewControllerError.notRegistered
|
|
}
|
|
|
|
// MARK: ShareViewDelegate, SAEFailedViewDelegate
|
|
|
|
public func shareViewWasUnlocked() {
|
|
Logger.info("")
|
|
|
|
presentContentView()
|
|
}
|
|
|
|
public func shareViewWasCompleted() {
|
|
Logger.info("")
|
|
dismissAndCompleteExtension(animated: true, error: nil)
|
|
}
|
|
|
|
public func shareViewWasCancelled() {
|
|
Logger.info("")
|
|
dismissAndCompleteExtension(animated: true, error: ShareViewControllerError.obsoleteShare)
|
|
}
|
|
|
|
public func shareViewFailed(error: Error) {
|
|
owsFailDebug("Error: \(error)")
|
|
dismissAndCompleteExtension(animated: true, error: error)
|
|
}
|
|
|
|
private func dismissAndCompleteExtension(animated: Bool, error: Error?) {
|
|
let extensionContext = self.extensionContext
|
|
dismiss(animated: animated) {
|
|
AssertIsOnMainThread()
|
|
|
|
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
|
|
|
|
// This view controller is not visible to the user. It exists to intercept touches, set up the
|
|
// extensions dependencies, and eventually present a visible view to the user.
|
|
// For speed of presentation, we only present a single modal, and if it's already been presented
|
|
// we swap out the contents.
|
|
// e.g. if loading is taking a while, the user will see the load screen presented with a modal
|
|
// animation. Next, when loading completes, the load view will be switched out for the contact
|
|
// picker view.
|
|
private func showPrimaryViewController(_ viewController: UIViewController) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let shareViewNavigationController = shareViewNavigationController else {
|
|
owsFailDebug("Missing shareViewNavigationController")
|
|
return
|
|
}
|
|
shareViewNavigationController.setViewControllers([viewController], animated: true)
|
|
if self.presentedViewController == nil {
|
|
Logger.debug("presenting modally: \(viewController)")
|
|
self.present(shareViewNavigationController, animated: true)
|
|
} else {
|
|
Logger.debug("modal already presented. swapping modal content for: \(viewController)")
|
|
assert(self.presentedViewController == shareViewNavigationController)
|
|
}
|
|
}
|
|
|
|
private lazy var conversationPicker = SharingThreadPickerViewController(shareViewDelegate: self)
|
|
private func buildAttachmentsAndPresentConversationPicker() {
|
|
let selectedThread: TSThread?
|
|
if let intent = extensionContext?.intent as? INSendMessageIntent,
|
|
let threadUniqueId = intent.conversationIdentifier {
|
|
selectedThread = databaseStorage.read { TSThread.anyFetch(uniqueId: threadUniqueId, transaction: $0) }
|
|
} else {
|
|
selectedThread = nil
|
|
}
|
|
|
|
// 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 selectedThread == nil {
|
|
showPrimaryViewController(conversationPicker)
|
|
}
|
|
|
|
loadTask?.cancel()
|
|
loadTask = Task {
|
|
do {
|
|
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 typedItemProviders = try Self.typedItemProviders(for: itemProviders)
|
|
self.conversationPicker.areAttachmentStoriesCompatPrecheck = typedItemProviders.allSatisfy { $0.isStoriesCompatible }
|
|
let attachments = try await self.buildAttachments(for: typedItemProviders)
|
|
try Task.checkCancellation()
|
|
|
|
// Make sure the user is not trying to share more than our attachment limit.
|
|
guard attachments.filter({ !$0.isConvertibleToTextMessage }).count <= SignalAttachment.maxAttachmentsAllowed else {
|
|
throw ShareViewControllerError.tooManyAttachments
|
|
}
|
|
|
|
self.progressPoller = nil
|
|
|
|
Logger.info("Setting picker attachments: \(attachments)")
|
|
self.conversationPicker.attachments = attachments
|
|
|
|
if let selectedThread = selectedThread {
|
|
let approvalVC = try self.conversationPicker.buildApprovalViewController(for: selectedThread)
|
|
self.showPrimaryViewController(approvalVC)
|
|
}
|
|
|
|
} catch 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(format: format, OWSFormat.formatInt(SignalAttachment.maxAttachmentsAllowed))
|
|
|
|
OWSActionSheets.showActionSheet(
|
|
title: alertTitle,
|
|
buttonTitle: CommonStrings.cancelButton
|
|
) { _ in
|
|
self.shareViewWasCancelled()
|
|
}
|
|
} catch {
|
|
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()
|
|
}
|
|
owsFailDebug("building attachment failed with error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func presentScreenLock() {
|
|
AssertIsOnMainThread()
|
|
|
|
let screenLockUI = SAEScreenLockViewController(shareViewDelegate: self)
|
|
Logger.debug("presentScreenLock: \(screenLockUI)")
|
|
showPrimaryViewController(screenLockUI)
|
|
Logger.info("showing screen lock")
|
|
}
|
|
|
|
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(kUTTypeData as String)
|
|
|| attachment.hasItemConformingToTypeIdentifier(kUTTypeFileURL as String)
|
|
|| attachment.hasItemConformingToTypeIdentifier("com.apple.pkpass") {
|
|
return extensionItem
|
|
}
|
|
}
|
|
}
|
|
throw ShareViewControllerError.noConformingInputItem
|
|
}
|
|
|
|
private struct TypedItemProvider {
|
|
enum ItemType {
|
|
case movie
|
|
case image
|
|
case webUrl
|
|
case fileUrl
|
|
case contact
|
|
case text
|
|
case pdf
|
|
case pkPass
|
|
case data
|
|
|
|
var typeIdentifier: String {
|
|
switch self {
|
|
case .movie:
|
|
return kUTTypeMovie as String
|
|
case .image:
|
|
return kUTTypeImage as String
|
|
case .webUrl:
|
|
return kUTTypeURL as String
|
|
case .fileUrl:
|
|
return kUTTypeFileURL as String
|
|
case .contact:
|
|
return kUTTypeVCard as String
|
|
case .text:
|
|
return kUTTypeText as String
|
|
case .pdf:
|
|
return kUTTypePDF as String
|
|
case .pkPass:
|
|
return "com.apple.pkpass"
|
|
case .data:
|
|
return kUTTypeData as String
|
|
}
|
|
}
|
|
}
|
|
|
|
let itemProvider: NSItemProvider
|
|
let itemType: ItemType
|
|
|
|
var isWebUrl: Bool {
|
|
itemType == .webUrl
|
|
}
|
|
|
|
var isVisualMedia: Bool {
|
|
itemType == .image || itemType == .movie
|
|
}
|
|
|
|
var isStoriesCompatible: Bool {
|
|
switch itemType {
|
|
case .movie, .image, .webUrl, .text:
|
|
return true
|
|
case .fileUrl, .contact, .pdf, .pkPass, .data:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func typedItemProviders(for itemProviders: [NSItemProvider]) throws -> [TypedItemProvider] {
|
|
// due to UT conformance fallbacks the order these are checked is important; more specific types need to come earlier in the list than their fallbacks
|
|
let itemTypeOrder: [TypedItemProvider.ItemType] = [.movie, .image, .contact, .text, .pdf, .pkPass, .fileUrl, .webUrl, .data]
|
|
let candidates: [TypedItemProvider] = try itemProviders.map { itemProvider in
|
|
for itemType in itemTypeOrder {
|
|
if itemProvider.hasItemConformingToTypeIdentifier(itemType.typeIdentifier) {
|
|
return TypedItemProvider(itemProvider: itemProvider, itemType: itemType)
|
|
}
|
|
}
|
|
owsFailDebug("unexpected share item: \(itemProvider)")
|
|
throw ShareViewControllerError.unsupportedMedia
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
nonisolated private func buildAttachments(for typedItemProviders: [TypedItemProvider]) async throws -> [SignalAttachment] {
|
|
|
|
// 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: [SignalAttachment] = []
|
|
for typedItemProvider in typedItemProviders {
|
|
result.append(try await self.buildAttachment(for: typedItemProvider))
|
|
}
|
|
return result
|
|
}
|
|
|
|
nonisolated private func buildAttachment(for typedItemProvider: TypedItemProvider) async throws -> SignalAttachment {
|
|
let itemProvider = typedItemProvider.itemProvider
|
|
switch typedItemProvider.itemType {
|
|
case .image:
|
|
// some apps send a usable file to us and some throw a UIImage at us, the UIImage can come in either directly
|
|
// or as a bplist containing the NSKeyedArchiver output of a UIImage. the code below executes the following
|
|
// order of attempts to load the input in the right way:
|
|
// 1) try attaching the image from a file so we don't have to load the image into RAM in the common case
|
|
// 2) try to load a UIImage directly in the case that is what was sent over
|
|
// 3) try to NSKeyedUnarchive NSData directly into a UIImage
|
|
do {
|
|
return try await self.buildFileAttachment(fromItemProvider: itemProvider, forTypeIdentifier: typedItemProvider.itemType.typeIdentifier)
|
|
} catch SignalAttachmentError.couldNotParseImage, ShareViewControllerError.fileUrlWasBplist {
|
|
Logger.warn("failed to parse image directly from file; checking for loading UIImage directly")
|
|
let image: UIImage = try await Self.loadObjectWithKeyedUnarchiverFallback(fromItemProvider: itemProvider, forTypeIdentifier: typedItemProvider.itemType.typeIdentifier, cannotLoadError: .cannotLoadUIImageObject, failedLoadError: .loadUIImageObjectFailed)
|
|
return try Self.createAttachment(withImage: image)
|
|
}
|
|
case .movie, .pdf, .data:
|
|
return try await self.buildFileAttachment(fromItemProvider: itemProvider, forTypeIdentifier: typedItemProvider.itemType.typeIdentifier)
|
|
case .fileUrl:
|
|
let url: NSURL = try await Self.loadObjectWithKeyedUnarchiverFallback(fromItemProvider: itemProvider, forTypeIdentifier: typedItemProvider.itemType.typeIdentifier, cannotLoadError: .cannotLoadURLObject, failedLoadError: .loadURLObjectFailed)
|
|
let attachment = try Self.copyAttachment(fromUrl: url as URL)
|
|
return try await self.compressVideo(attachment: attachment)
|
|
case .webUrl:
|
|
let url: NSURL = try await Self.loadObjectWithKeyedUnarchiverFallback(fromItemProvider: itemProvider, forTypeIdentifier: typedItemProvider.itemType.typeIdentifier, cannotLoadError: .cannotLoadURLObject, failedLoadError: .loadURLObjectFailed)
|
|
return try Self.createAttachment(withText: (url as URL).absoluteString)
|
|
case .contact:
|
|
let contactData = try await Self.loadDataRepresentation(fromItemProvider: itemProvider, forTypeIdentifier: kUTTypeContact as String)
|
|
let dataSource = DataSourceValue.dataSource(with: contactData, utiType: kUTTypeContact as String)
|
|
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeContact as String)
|
|
attachment.isConvertibleToContactShare = true
|
|
if let attachmentError = attachment.error {
|
|
throw attachmentError
|
|
}
|
|
return attachment
|
|
case .text:
|
|
let text: NSString = try await Self.loadObjectWithKeyedUnarchiverFallback(fromItemProvider: itemProvider, forTypeIdentifier: typedItemProvider.itemType.typeIdentifier, cannotLoadError: .cannotLoadStringObject, failedLoadError: .loadStringObjectFailed)
|
|
return try Self.createAttachment(withText: text as String)
|
|
case .pkPass:
|
|
let typeIdentifier = "com.apple.pkpass"
|
|
let pkPass = try await Self.loadDataRepresentation(fromItemProvider: itemProvider, forTypeIdentifier: typeIdentifier)
|
|
let dataSource = DataSourceValue.dataSource(with: pkPass, fileExtension: "pkpass")
|
|
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: typeIdentifier)
|
|
if let attachmentError = attachment.error {
|
|
throw attachmentError
|
|
}
|
|
return attachment
|
|
}
|
|
}
|
|
|
|
nonisolated private static func copyAttachment(fromUrl url: URL, defaultTypeIdentifier: String = kUTTypeData as String) throws -> SignalAttachment {
|
|
guard let dataSource = try? DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false) else {
|
|
throw ShareViewControllerError.nonFileUrl
|
|
}
|
|
dataSource.sourceFilename = url.lastPathComponent
|
|
let utiType = MimeTypeUtil.utiTypeForFileExtension(url.pathExtension) ?? defaultTypeIdentifier
|
|
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: utiType)
|
|
if let attachmentError = attachment.error {
|
|
throw attachmentError
|
|
}
|
|
return try attachment.cloneAttachment()
|
|
}
|
|
|
|
nonisolated private func compressVideo(attachment: SignalAttachment) async throws -> SignalAttachment {
|
|
if attachment.isVideoThatNeedsCompression() {
|
|
// TODO: Move waiting for this export to the end of the share flow rather than up front
|
|
let compressedAttachment = try await SignalAttachment.compressVideoAsMp4(dataSource: attachment.dataSource, dataUTI: attachment.dataUTI, sessionCallback: { exportSession in
|
|
Task { @MainActor in
|
|
let progressPoller = ProgressPoller(timeInterval: 0.1, ratioCompleteBlock: { return exportSession.progress })
|
|
|
|
self.progressPoller = progressPoller
|
|
progressPoller.startPolling()
|
|
|
|
self.loadViewController.progress = progressPoller.progress
|
|
}
|
|
})
|
|
if let attachmentError = compressedAttachment.error {
|
|
throw attachmentError
|
|
}
|
|
return compressedAttachment
|
|
} else {
|
|
return attachment
|
|
}
|
|
}
|
|
|
|
nonisolated private func buildFileAttachment(fromItemProvider itemProvider: NSItemProvider, forTypeIdentifier typeIdentifier: String) async throws -> SignalAttachment {
|
|
let attachment: SignalAttachment = try await withCheckedThrowingContinuation { continuation in
|
|
_ = itemProvider.loadInPlaceFileRepresentation(forTypeIdentifier: typeIdentifier, completionHandler: { fileUrl, _, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else if let fileUrl {
|
|
if Self.isBplist(url: fileUrl) {
|
|
continuation.resume(throwing: ShareViewControllerError.fileUrlWasBplist)
|
|
} else {
|
|
do {
|
|
// NOTE: Compression here rather than creating an additional temp file would be nice but blocking this completion handler for video encoding is probably not a good way to go.
|
|
continuation.resume(returning: try Self.copyAttachment(fromUrl: fileUrl, defaultTypeIdentifier: typeIdentifier))
|
|
} catch {
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
} else {
|
|
continuation.resume(throwing: ShareViewControllerError.loadInPlaceFileRepresentationFailed)
|
|
}
|
|
})
|
|
}
|
|
|
|
if let attachmentError = attachment.error {
|
|
throw attachmentError
|
|
}
|
|
|
|
return try await self.compressVideo(attachment: attachment)
|
|
}
|
|
|
|
nonisolated private static func loadDataRepresentation(fromItemProvider itemProvider: NSItemProvider, forTypeIdentifier typeIdentifier: String) async throws -> Data {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
_ = itemProvider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else if let data {
|
|
continuation.resume(returning: data)
|
|
} else {
|
|
continuation.resume(throwing: ShareViewControllerError.loadDataRepresentationFailed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated private static func loadObjectWithKeyedUnarchiverFallback<T>(fromItemProvider itemProvider: NSItemProvider,
|
|
forTypeIdentifier typeIdentifier: String,
|
|
cannotLoadError: ShareViewControllerError,
|
|
failedLoadError: ShareViewControllerError) async throws -> T
|
|
where T: NSItemProviderReading, T: NSCoding, T: NSObject {
|
|
do {
|
|
guard itemProvider.canLoadObject(ofClass: T.self) else {
|
|
throw cannotLoadError
|
|
}
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
_ = itemProvider.loadObject(ofClass: T.self) { object, error in
|
|
if let error {
|
|
continuation.resume(throwing: error)
|
|
} else if let typedObject = object as? T {
|
|
continuation.resume(returning: typedObject)
|
|
} else {
|
|
continuation.resume(throwing: failedLoadError)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
let data = try await loadDataRepresentation(fromItemProvider: itemProvider, forTypeIdentifier: typeIdentifier)
|
|
if let result = try? NSKeyedUnarchiver.unarchivedObject(ofClass: T.self, from: data) {
|
|
return result
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated private static func createAttachment(withText text: String) throws -> SignalAttachment {
|
|
let dataSource = DataSourceValue.dataSource(withOversizeText: text)
|
|
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeText as String)
|
|
if let attachmentError = attachment.error {
|
|
throw attachmentError
|
|
}
|
|
attachment.isConvertibleToTextMessage = true
|
|
return attachment
|
|
}
|
|
|
|
nonisolated private static func createAttachment(withImage image: UIImage) throws -> SignalAttachment {
|
|
guard let imagePng = image.pngData() else {
|
|
throw ShareViewControllerError.uiImageMissingOrCorruptImageData
|
|
}
|
|
let typeIdentifier = kUTTypePNG as String
|
|
let dataSource = DataSourceValue.dataSource(with: imagePng, utiType: typeIdentifier)
|
|
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: typeIdentifier)
|
|
if let attachmentError = attachment.error {
|
|
throw attachmentError
|
|
}
|
|
return attachment
|
|
}
|
|
|
|
nonisolated private static func isBplist(url: URL) -> Bool {
|
|
if let handle = try? FileHandle(forReadingFrom: url) {
|
|
let data = handle.readData(ofLength: 6)
|
|
return data == "bplist".data(using: .utf8)
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ShareViewController: UIAdaptivePresentationControllerDelegate {
|
|
public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
shareViewWasCancelled()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ShareViewController: UINavigationControllerDelegate {
|
|
|
|
public func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
|
updateNavigationBarVisibility(for: viewController, in: navigationController, animated: animated)
|
|
}
|
|
|
|
public func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
|
|
updateNavigationBarVisibility(for: viewController, in: navigationController, animated: animated)
|
|
}
|
|
|
|
private func updateNavigationBarVisibility(for viewController: UIViewController,
|
|
in navigationController: UINavigationController,
|
|
animated: Bool) {
|
|
switch viewController {
|
|
case is AttachmentApprovalViewController:
|
|
navigationController.setNavigationBarHidden(true, animated: animated)
|
|
default:
|
|
navigationController.setNavigationBarHidden(false, animated: animated)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exposes a Progress object, whose progress is updated by polling the return of a given block
|
|
private class ProgressPoller: NSObject {
|
|
|
|
let progress: Progress
|
|
private(set) var timer: Timer?
|
|
|
|
// Higher number offers higher ganularity
|
|
let progressTotalUnitCount: Int64 = 10000
|
|
private let timeInterval: Double
|
|
private let ratioCompleteBlock: () -> Float
|
|
|
|
init(timeInterval: TimeInterval, ratioCompleteBlock: @escaping () -> Float) {
|
|
self.timeInterval = timeInterval
|
|
self.ratioCompleteBlock = ratioCompleteBlock
|
|
|
|
self.progress = Progress()
|
|
|
|
progress.totalUnitCount = progressTotalUnitCount
|
|
progress.completedUnitCount = Int64(ratioCompleteBlock() * Float(progressTotalUnitCount))
|
|
}
|
|
|
|
func startPolling() {
|
|
guard self.timer == nil else {
|
|
owsFailDebug("already started timer")
|
|
return
|
|
}
|
|
|
|
self.timer = WeakTimer.scheduledTimer(timeInterval: timeInterval, target: self, userInfo: nil, repeats: true) { [weak self] (timer) in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
|
|
let completedUnitCount = Int64(strongSelf.ratioCompleteBlock() * Float(strongSelf.progressTotalUnitCount))
|
|
strongSelf.progress.completedUnitCount = completedUnitCount
|
|
|
|
if completedUnitCount == strongSelf.progressTotalUnitCount {
|
|
Logger.debug("progress complete")
|
|
timer.invalidate()
|
|
}
|
|
}
|
|
}
|
|
}
|