Signal-iOS/SignalShareExtension/ShareViewController.swift
2021-02-23 10:45:50 -08:00

999 lines
40 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import UIKit
import SignalMessaging
import PureLayout
import SignalServiceKit
import PromiseKit
import Intents
@objc
public class ShareViewController: UIViewController, ShareViewDelegate, SAEFailedViewDelegate {
enum ShareViewControllerError: Error {
case assertionError(description: String)
case unsupportedMedia
case notRegistered
case obsoleteShare
}
private var hasInitialRootViewController = false
private var isReadyForAppExtensions = false
private var areVersionMigrationsComplete = false
private var progressPoller: ProgressPoller?
lazy var loadViewController = SAELoadViewController(delegate: self)
public var shareViewNavigationController: OWSNavigationController?
override open func loadView() {
super.loadView()
// This should be the first thing we do.
let appContext = ShareAppExtensionContext(rootViewController: self)
SetCurrentAppContext(appContext)
DebugLogger.shared().enableTTYLogging()
if _isDebugAssertConfiguration() {
DebugLogger.shared().enableFileLogging()
} else if OWSPreferences.isLoggingEnabled() {
DebugLogger.shared().enableFileLogging()
}
Logger.info("")
_ = AppVersion.shared()
Cryptography.seedRandom()
// We don't need to use DeviceSleepManager in the SAE.
// We don't need to use applySignalAppearence in the SAE.
if CurrentAppContext().isRunningTests {
// TODO: Do we need to implement isRunningTests in the SAE context?
return
}
// If we need to migrate YDB-to-GRDB, show an error view and abort.
guard StorageCoordinator.isReadyForShareExtension else {
showNotReadyView()
return
}
// If we haven't migrated the database file to the shared data
// directory we can't load it, and therefore can't init TSSPrimaryStorage,
// and therefore don't want to setup most of our machinery (Environment,
// most of the singletons, etc.). We just want to show an error view and
// abort.
isReadyForAppExtensions = OWSPreferences.isReadyForAppExtensions()
guard isReadyForAppExtensions else {
showNotReadyView()
return
}
// We shouldn't set up our environment until after we've consulted isReadyForAppExtensions.
AppSetup.setupEnvironment(appSpecificSingletonBlock: {
SSKEnvironment.shared.callMessageHandler = NoopCallMessageHandler()
SSKEnvironment.shared.notificationsManager = NoopNotificationsManager()
},
migrationCompletion: { [weak self] in
AssertIsOnMainThread()
guard let strongSelf = self else { return }
// performUpdateCheck must be invoked after Environment has been initialized because
// upgrade process may depend on Environment.
strongSelf.versionMigrationsDidComplete()
})
let shareViewNavigationController = OWSNavigationController()
shareViewNavigationController.presentationController?.delegate = self
self.shareViewNavigationController = shareViewNavigationController
// Don't display load screen immediately, in hopes that we can avoid it altogether.
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(storageIsReady),
name: .StorageIsReady,
object: nil)
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.")
OWSAnalytics.appLaunchDidBegin()
}
deinit {
Logger.info("deinit")
NotificationCenter.default.removeObserver(self)
// 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.
ExitShareExtension()
}
@objc
public func applicationDidEnterBackground() {
AssertIsOnMainThread()
Logger.info("")
if OWSScreenLock.shared.isScreenLockEnabled() {
Logger.info("dismissing.")
self.dismiss(animated: false) { [weak self] in
AssertIsOnMainThread()
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
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.
TSPreKeyManager.checkPreKeysIfNecessary()
// We don't need to use RTCInitializeSSL() in the SAE.
if tsAccountManager.isRegistered {
Logger.info("running post launch block for registered user: \(String(describing: TSAccountManager.localAddress))")
} 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 TSSocketManager in the SAE.
}
if tsAccountManager.isRegistered {
DispatchQueue.main.async { [weak self] in
guard let _ = self else { return }
Logger.info("running post launch block for registered user: \(String(describing: TSAccountManager.localAddress))")
// We don't need to use the TSSocketManager in the SAE.
// TODO: Re-enable when system contact fetching uses less memory.
// Environment.shared.contactsManager.fetchSystemContactsOnceIfAlreadyAuthorized()
// We don't need to fetch messages in the SAE.
// We don't need to use OWSSyncPushTokensJob in the SAE.
}
}
}
@objc
func versionMigrationsDidComplete() {
AssertIsOnMainThread()
Logger.debug("")
areVersionMigrationsComplete = true
checkIsAppReady()
}
@objc
func storageIsReady() {
AssertIsOnMainThread()
Logger.debug("")
checkIsAppReady()
}
@objc
func checkIsAppReady() {
AssertIsOnMainThread()
// App isn't ready until storage is ready AND all version migrations are complete.
guard areVersionMigrationsComplete else {
return
}
guard storageCoordinator.isStorageReady else {
return
}
guard !AppReadiness.isAppReady else {
// Only mark the app as ready once.
return
}
// We don't need to use LaunchJobs in the SAE.
Logger.debug("")
// Note that this does much more than set a flag;
// it will also run all deferred blocks.
AppReadiness.setAppIsReady()
if tsAccountManager.isRegistered {
Logger.info("localAddress: \(String(describing: TSAccountManager.localAddress))")
// 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.
AppVersion.shared().saeLaunchDidComplete()
ensureRootViewController()
// We don't need to use OWSOrphanDataCleaner in the SAE.
// We don't need to fetch the local profile in the SAE
}
@objc
func registrationStateDidChange() {
AssertIsOnMainThread()
Logger.debug("")
if tsAccountManager.isRegistered {
Logger.info("localAddress: \(String(describing: TSAccountManager.localAddress))")
// 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 OWSScreenLock.shared.isScreenLockEnabled() {
presentScreenLock()
} else {
presentContentView()
}
}
private func presentContentView() {
AssertIsOnMainThread()
Logger.debug("")
Logger.info("Presenting content view")
guard tsAccountManager.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
}
guard tsAccountManager.isOnboarded() else {
showNotReadyView()
return
}
buildAttachmentsAndPresentConversationPicker()
// We don't use the AppUpdateNag in the SAE.
}
// MARK: Error Views
private func showNotReadyView() {
AssertIsOnMainThread()
let failureTitle = NSLocalizedString("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 = NSLocalizedString("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 = NSLocalizedString("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 = NSLocalizedString("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()
guard let strongSelf = self else { return }
strongSelf.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)
Logger.flush()
// 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.
//
// We do this here, because since iOS 13 `viewDidDisappear` is never called.
DispatchQueue.main.async { ExitShareExtension() }
}
@objc
func owsApplicationWillEnterForeground() throws {
AssertIsOnMainThread()
Logger.debug("")
// If a user unregisters in the main app, the SAE should shut down
// immediately.
guard !tsAccountManager.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 let _ = firstViewController as? 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("")
self.dismiss(animated: true) { [weak self] in
AssertIsOnMainThread()
guard let strongSelf = self else { return }
strongSelf.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
public func shareViewWasCancelled() {
Logger.info("")
self.dismiss(animated: true) { [weak self] in
AssertIsOnMainThread()
guard let strongSelf = self else { return }
strongSelf.extensionContext?.cancelRequest(withError: ShareViewControllerError.obsoleteShare)
}
}
public func shareViewFailed(error: Error) {
owsFailDebug("Error: \(error)")
self.dismiss(animated: true) { [weak self] in
AssertIsOnMainThread()
guard let strongSelf = self else { return }
strongSelf.extensionContext?.cancelRequest(withError: error)
}
}
// 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 #available(iOS 13, *),
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
// immeidately and tell it what attachments we're sharing once we've
// finished building them.
if selectedThread == nil { showPrimaryViewController(conversationPicker) }
firstly(on: .sharedUserInitiated) { () -> Promise<[UnloadedItem]> in
guard let inputItems = self.extensionContext?.inputItems as? [NSExtensionItem] else {
throw OWSAssertionError("no input item")
}
let result = try self.itemsToLoad(inputItems: inputItems)
return Promise.value(result)
}.then(on: .sharedUserInitiated) { [weak self] (unloadedItems: [UnloadedItem]) -> Promise<[LoadedItem]> in
guard let self = self else { throw PMKError.cancelled }
return self.loadItems(unloadedItems: unloadedItems)
}.then(on: .sharedUserInitiated) { [weak self] (loadedItems: [LoadedItem]) -> Promise<[SignalAttachment]> in
guard let self = self else { throw PMKError.cancelled }
return self.buildAttachments(loadedItems: loadedItems)
}.done { [weak self] (attachments: [SignalAttachment]) in
guard let self = self else { throw PMKError.cancelled }
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 { [weak self] error in
guard let self = self else { return }
let alertTitle = NSLocalizedString("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.localizedDescription,
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 class func itemMatchesSpecificUtiType(itemProvider: NSItemProvider, utiType: String) -> Bool {
// URLs, contacts and other special items have to be detected separately.
// Many shares (e.g. pdfs) will register many UTI types and/or conform to kUTTypeData.
guard itemProvider.registeredTypeIdentifiers.count == 1 else {
return false
}
guard let firstUtiType = itemProvider.registeredTypeIdentifiers.first else {
return false
}
return firstUtiType == utiType
}
private class func isVisualMediaItem(itemProvider: NSItemProvider) -> Bool {
return (itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) ||
itemProvider.hasItemConformingToTypeIdentifier(kUTTypeMovie as String))
}
private class func isUrlItem(itemProvider: NSItemProvider) -> Bool {
return itemMatchesSpecificUtiType(itemProvider: itemProvider,
utiType: kUTTypeURL as String)
}
private class func isContactItem(itemProvider: NSItemProvider) -> Bool {
return itemMatchesSpecificUtiType(itemProvider: itemProvider,
utiType: kUTTypeContact as String)
}
private func itemsToLoad(inputItems: [NSExtensionItem]) throws -> [UnloadedItem] {
for inputItem in inputItems {
guard let itemProviders = inputItem.attachments else {
throw OWSAssertionError("attachments was empty")
}
let itemsToLoad: [UnloadedItem] = itemProviders.map { itemProvider in
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
return UnloadedItem(itemProvider: itemProvider, itemType: .movie)
}
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
return UnloadedItem(itemProvider: itemProvider, itemType: .image)
}
// A single inputItem can have multiple attachments, e.g. sharing from Firefox gives
// one url attachment and another text attachment, where the the url would be https://some-news.com/articles/123-cat-stuck-in-tree
// and the text attachment would be something like "Breaking news - cat stuck in tree"
//
// FIXME: For now, we prefer the URL provider and discard the text provider, since it's more useful to share the URL than the caption
// but we *should* include both. This will be a bigger change though since our share extension is currently heavily predicated
// on one itemProvider per share.
if ShareViewController.isUrlItem(itemProvider: itemProvider) {
return UnloadedItem(itemProvider: itemProvider, itemType: .webUrl)
}
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeFileURL as String) {
return UnloadedItem(itemProvider: itemProvider, itemType: .fileUrl)
}
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeVCard as String) {
return UnloadedItem(itemProvider: itemProvider, itemType: .contact)
}
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
return UnloadedItem(itemProvider: itemProvider, itemType: .text)
}
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypePDF as String) {
return UnloadedItem(itemProvider: itemProvider, itemType: .pdf)
}
owsFailDebug("unexpected share item: \(itemProvider)")
return UnloadedItem(itemProvider: itemProvider, itemType: .other)
}
// Prefer a URL if available. If there's an image item and a URL item,
// the URL is generally more useful. e.g. when sharing an app from the
// App Store the image would be the app icon and the URL is the link
// to the application.
if let urlItem = itemsToLoad.first(where: { $0.itemType == .webUrl }) {
return [urlItem]
}
let visualMediaItems = itemsToLoad.filter { ShareViewController.isVisualMediaItem(itemProvider: $0.itemProvider) }
// We only allow sharing 1 item, unless they are visual media items. And if they are
// visualMediaItems we share *only* the visual media items - a mix of visual and non
// visual items is not supported.
if visualMediaItems.count > 0 {
return visualMediaItems
} else if itemsToLoad.count > 0 {
return Array(itemsToLoad.prefix(1))
}
}
throw OWSAssertionError("no input item")
}
private
struct LoadedItem {
enum LoadedItemPayload {
case fileUrl(_ fileUrl: URL)
case inMemoryImage(_ image: UIImage)
case webUrl(_ webUrl: URL)
case contact(_ contactData: Data)
case text(_ text: String)
case pdf(_ data: Data)
var debugDescription: String {
switch self {
case .fileUrl:
return "fileUrl"
case .inMemoryImage:
return "inMemoryImage"
case .webUrl:
return "webUrl"
case .contact:
return "contact"
case .text:
return "text"
case .pdf:
return "pdf"
@unknown default:
owsFailDebug("Unknown value.")
return "unknown"
}
}
}
let itemProvider: NSItemProvider
let payload: LoadedItemPayload
var customFileName: String? {
isContactShare ? "Contact.vcf" : nil
}
private var isContactShare: Bool {
if case .contact = payload {
return true
} else {
return false
}
}
var debugDescription: String {
payload.debugDescription
}
}
private
struct UnloadedItem {
enum ItemType {
case movie
case image
case webUrl
case fileUrl
case contact
case text
case pdf
case other
}
let itemProvider: NSItemProvider
let itemType: ItemType
}
private func loadItems(unloadedItems: [UnloadedItem]) -> Promise<[LoadedItem]> {
let loadPromises: [Promise<LoadedItem>] = unloadedItems.map { unloadedItem in
loadItem(unloadedItem: unloadedItem)
}
return when(fulfilled: loadPromises)
}
private func loadItem(unloadedItem: UnloadedItem) -> Promise<LoadedItem> {
Logger.info("unloadedItem: \(unloadedItem)")
let itemProvider = unloadedItem.itemProvider
switch unloadedItem.itemType {
case .movie:
return itemProvider.loadUrl(forTypeIdentifier: kUTTypeMovie as String, options: nil).map { fileUrl in
LoadedItem(itemProvider: unloadedItem.itemProvider,
payload: .fileUrl(fileUrl))
}
case .image:
// When multiple image formats are available, kUTTypeImage will
// defer to jpeg when possible. On iPhone 12 Pro, when 'heic'
// and 'jpeg' are the available options, the 'jpeg' data breaks
// UIImage (and underlying) in some unclear way such that trying
// to perform any kind of transformation on the image (such as
// resizing) causes memory to balloon uncontrolled. Luckily,
// iOS 14 provides native UIImage support for heic and iPhone
// 12s can only be running iOS 14+, so we can request the heic
// format directly, which behaves correctly for all our needs.
// A radar has been opened with apple reporting this issue.
let desiredTypeIdentifier: String
if #available(iOS 14, *), itemProvider.registeredTypeIdentifiers.contains("public.heic") {
desiredTypeIdentifier = "public.heic"
} else {
desiredTypeIdentifier = kUTTypeImage as String
}
return itemProvider.loadUrl(forTypeIdentifier: desiredTypeIdentifier, options: nil).map { fileUrl in
LoadedItem(itemProvider: unloadedItem.itemProvider,
payload: .fileUrl(fileUrl))
}.recover(on: .global()) { error -> Promise<LoadedItem> in
let nsError = error as NSError
assert(nsError.domain == NSItemProvider.errorDomain)
assert(nsError.code == NSItemProvider.ErrorCode.unexpectedValueClassError.rawValue)
// If a URL wasn't available, fall back to an in-memory image.
// One place this happens is when sharing from the screenshot app on iOS13.
return itemProvider.loadImage(forTypeIdentifier: kUTTypeImage as String, options: nil).map { image in
LoadedItem(itemProvider: unloadedItem.itemProvider,
payload: .inMemoryImage(image))
}
}
case .webUrl:
return itemProvider.loadUrl(forTypeIdentifier: kUTTypeURL as String, options: nil).map { url in
LoadedItem(itemProvider: unloadedItem.itemProvider,
payload: .webUrl(url))
}
case .fileUrl:
return itemProvider.loadUrl(forTypeIdentifier: kUTTypeFileURL as String, options: nil).map { fileUrl in
LoadedItem(itemProvider: unloadedItem.itemProvider,
payload: .fileUrl(fileUrl))
}
case .contact:
return itemProvider.loadData(forTypeIdentifier: kUTTypeContact as String, options: nil).map { contactData in
LoadedItem(itemProvider: unloadedItem.itemProvider,
payload: .contact(contactData))
}
case .text:
return itemProvider.loadText(forTypeIdentifier: kUTTypeText as String, options: nil).map { text in
LoadedItem(itemProvider: unloadedItem.itemProvider,
payload: .text(text))
}
case .pdf:
return itemProvider.loadData(forTypeIdentifier: kUTTypePDF as String, options: nil).map { data in
LoadedItem(itemProvider: unloadedItem.itemProvider,
payload: .pdf(data))
}
case .other:
return itemProvider.loadUrl(forTypeIdentifier: kUTTypeFileURL as String, options: nil).map { fileUrl in
LoadedItem(itemProvider: unloadedItem.itemProvider,
payload: .fileUrl(fileUrl))
}
}
}
private func buildAttachments(loadedItems: [LoadedItem]) -> Promise<[SignalAttachment]> {
var attachmentPromises = [Promise<SignalAttachment>]()
for loadedItem in loadedItems {
attachmentPromises.append(firstly(on: .sharedUserInitiated) { () -> Promise<SignalAttachment> in
self.buildAttachment(loadedItem: loadedItem)
})
}
return when(fulfilled: attachmentPromises)
}
private func buildAttachment(loadedItem: LoadedItem) -> Promise<SignalAttachment> {
let itemProvider = loadedItem.itemProvider
switch loadedItem.payload {
case .webUrl(let webUrl):
let dataSource = DataSourceValue.dataSource(withOversizeText: webUrl.absoluteString)
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeText as String)
attachment.isConvertibleToTextMessage = true
return Promise.value(attachment)
case .contact(let contactData):
let dataSource = DataSourceValue.dataSource(with: contactData, utiType: kUTTypeContact as String)
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeContact as String)
attachment.isConvertibleToContactShare = true
return Promise.value(attachment)
case .text(let text):
let dataSource = DataSourceValue.dataSource(withOversizeText: text)
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeText as String)
attachment.isConvertibleToTextMessage = true
return Promise.value(attachment)
case .fileUrl(let itemUrl):
var url = itemUrl
do {
if isVideoNeedingRelocation(itemProvider: itemProvider, itemUrl: itemUrl) {
url = try SignalAttachment.copyToVideoTempDir(url: itemUrl)
}
} catch {
let error = ShareViewControllerError.assertionError(description: "Could not copy video")
return Promise(error: error)
}
guard let dataSource = try? DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false) else {
let error = ShareViewControllerError.assertionError(description: "Unable to read attachment data")
return Promise(error: error)
}
dataSource.sourceFilename = url.lastPathComponent
let utiType = MIMETypeUtil.utiType(forFileExtension: url.pathExtension) ?? kUTTypeData as String
guard !SignalAttachment.isVideoThatNeedsCompression(dataSource: dataSource, dataUTI: utiType) else {
// This can happen, e.g. when sharing a quicktime-video from iCloud drive.
let (promise, exportSession) = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: utiType)
// TODO: How can we move waiting for this export to the end of the share flow rather than having to do it up front?
// Ideally we'd be able to start it here, and not block the UI on conversion unless there's still work to be done
// when the user hits "send".
if let exportSession = exportSession {
DispatchQueue.main.async {
let progressPoller = ProgressPoller(timeInterval: 0.1, ratioCompleteBlock: { return exportSession.progress })
self.progressPoller = progressPoller
progressPoller.startPolling()
self.loadViewController.progress = progressPoller.progress
}
}
return promise
}
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: utiType, imageQuality: .medium)
return Promise.value(attachment)
case .inMemoryImage(let image):
guard let pngData = image.pngData() else {
return Promise(error: OWSAssertionError("pngData was unexpectedly nil"))
}
let dataSource = DataSourceValue.dataSource(with: pngData, fileExtension: "png")
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypePNG as String, imageQuality: .medium)
return Promise.value(attachment)
case .pdf(let pdf):
let dataSource = DataSourceValue.dataSource(with: pdf, fileExtension: "pdf")
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypePDF as String)
return Promise.value(attachment)
}
}
// Some host apps (e.g. iOS Photos.app) sometimes auto-converts some video formats (e.g. com.apple.quicktime-movie)
// into mp4s as part of the NSItemProvider `loadItem` API. (Some files the Photo's app doesn't auto-convert)
//
// However, when using this url to the converted item, AVFoundation operations such as generating a
// preview image and playing the url in the AVMoviePlayer fails with an unhelpful error: "The operation could not be completed"
//
// We can work around this by first copying the media into our container.
//
// I don't understand why this is, and I haven't found any relevant documentation in the NSItemProvider
// or AVFoundation docs.
//
// Notes:
//
// These operations succeed when sending a video which initially existed on disk as an mp4.
// (e.g. Alice sends a video to Bob through the main app, which ensures it's an mp4. Bob saves it, then re-shares it)
//
// I *did* verify that the size and SHA256 sum of the original url matches that of the copied url. So there
// is no difference between the contents of the file, yet one works one doesn't.
// Perhaps the AVFoundation APIs require some extra file system permssion we don't have in the
// passed through URL.
private func isVideoNeedingRelocation(itemProvider: NSItemProvider, itemUrl: URL) -> Bool {
let pathExtension = itemUrl.pathExtension
guard pathExtension.count > 0 else {
Logger.verbose("item URL has no file extension: \(itemUrl).")
return false
}
guard let utiTypeForURL = MIMETypeUtil.utiType(forFileExtension: pathExtension) else {
Logger.verbose("item has unknown UTI type: \(itemUrl).")
return false
}
Logger.verbose("utiTypeForURL: \(utiTypeForURL)")
guard utiTypeForURL == kUTTypeMPEG4 as String else {
// Either it's not a video or it was a video which was not auto-converted to mp4.
// Not affected by the issue.
return false
}
// If video file already existed on disk as an mp4, then the host app didn't need to
// apply any conversion, so no need to relocate the file.
return !itemProvider.registeredTypeIdentifiers.contains(kUTTypeMPEG4 as String)
}
}
extension ShareViewController: UIAdaptivePresentationControllerDelegate {
public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
shareViewWasCancelled()
}
}
// 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()
}
}
}
}