session-ios/SessionShareExtension/ShareVC.swift

635 lines
27 KiB
Swift

import CoreServices
import PromiseKit
import SessionUIKit
final class ShareVC : UINavigationController, ShareViewDelegate, AppModeManagerDelegate {
private var areVersionMigrationsComplete = false
public static var attachmentPrepPromise: Promise<[SignalAttachment]>?
// MARK: Error
enum ShareViewControllerError: Error {
case assertionError(description: String)
case unsupportedMedia
case notRegistered
case obsoleteShare
}
// MARK: Lifecycle
override func loadView() {
super.loadView()
// This should be the first thing we do.
let appContext = ShareAppExtensionContext(rootViewController: self)
SetCurrentAppContext(appContext)
AppModeManager.configure(delegate: self)
Logger.info("")
_ = AppVersion.sharedInstance()
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
}
AppSetup.setupEnvironment(appSpecificSingletonBlock: {
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()
})
// 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(applicationDidEnterBackground),
name: .OWSApplicationDidEnterBackground,
object: nil)
}
@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 OWSStorage.isStorageReady() else {
return
}
guard !AppReadiness.isAppReady() else {
// Only mark the app as ready once.
return
}
SignalUtilitiesKit.Configuration.performMainSetup()
Logger.debug("")
// Note that this does much more than set a flag;
// it will also run all deferred blocks.
AppReadiness.setAppIsReady()
// 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.sharedInstance().saeLaunchDidComplete()
showLockScreenOrMainContent()
// We don't need to use OWSMessageReceiver in the SAE.
// We don't need to use OWSBatchMessageProcessor in the SAE.
// We don't need to use OWSOrphanDataCleaner in the SAE.
// We don't need to fetch the local profile in the SAE
OWSReadReceiptManager.shared().prepareCachedValues()
}
override func viewDidLoad() {
super.viewDidLoad()
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
AssertIsOnMainThread()
guard let strongSelf = self else { return }
strongSelf.showLockScreenOrMainContent()
}
}
@objc
public func applicationDidEnterBackground() {
AssertIsOnMainThread()
Logger.info("")
if OWSScreenLock.shared.isScreenLockEnabled() {
self.dismiss(animated: false) { [weak self] in
AssertIsOnMainThread()
guard let strongSelf = self else { return }
strongSelf.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
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()
}
// MARK: App Mode
public func getCurrentAppMode() -> AppMode {
guard let window = self.view.window else { return .light }
let userInterfaceStyle = window.traitCollection.userInterfaceStyle
let isLightMode = (userInterfaceStyle == .light || userInterfaceStyle == .unspecified)
return isLightMode ? .light : .dark
}
public func setCurrentAppMode(to appMode: AppMode) {
return // Not applicable to share extensions
}
// MARK: Updating
private func showLockScreenOrMainContent() {
if OWSScreenLock.shared.isScreenLockEnabled() {
showLockScreen()
} else {
showMainContent()
}
}
private func showLockScreen() {
let screenLockVC = SAEScreenLockViewController(shareViewDelegate: self)
setViewControllers([ screenLockVC ], animated: false)
}
private func showMainContent() {
let threadPickerVC = ThreadPickerVC()
threadPickerVC.shareVC = self
setViewControllers([ threadPickerVC ], animated: false)
let promise = buildAttachments()
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false, message: NSLocalizedString("vc_share_loading_message", comment: "")) { activityIndicator in
promise.done { _ in
activityIndicator.dismiss { }
}.catch { _ in
activityIndicator.dismiss { }
}
}
ShareVC.attachmentPrepPromise = promise
}
func shareViewWasUnlocked() {
showMainContent()
}
func shareViewWasCompleted() {
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
func shareViewWasCancelled() {
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
func shareViewFailed(error: Error) {
let alert = UIAlertController(title: "Session", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: { _ in
self.extensionContext!.cancelRequest(withError: error)
}))
present(alert, animated: true, completion: nil)
}
// MARK: Attachment Prep
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 class func utiType(itemProvider: NSItemProvider) -> String? {
Logger.info("utiTypeForItem: \(itemProvider.registeredTypeIdentifiers)")
if isUrlItem(itemProvider: itemProvider) {
return kUTTypeURL as String
} else if isContactItem(itemProvider: itemProvider) {
return kUTTypeContact as String
}
// Use the first UTI that conforms to "data".
let matchingUtiType = itemProvider.registeredTypeIdentifiers.first { (utiType: String) -> Bool in
UTTypeConformsTo(utiType as CFString, kUTTypeData)
}
return matchingUtiType
}
private class func createDataSource(utiType: String, url: URL, customFileName: String?) -> DataSource? {
if utiType == (kUTTypeURL as String) {
// Share URLs as oversize text messages whose text content is the URL.
//
// NOTE: SharingThreadPickerViewController will try to unpack them
// and send them as normal text messages if possible.
let urlString = url.absoluteString
return DataSourceValue.dataSource(withOversizeText: urlString)
} else if UTTypeConformsTo(utiType as CFString, kUTTypeText) {
// Share text as oversize text messages.
//
// NOTE: SharingThreadPickerViewController will try to unpack them
// and send them as normal text messages if possible.
return DataSourcePath.dataSource(with: url,
shouldDeleteOnDeallocation: false)
} else {
guard let dataSource = DataSourcePath.dataSource(with: url,
shouldDeleteOnDeallocation: false) else {
return nil
}
if let customFileName = customFileName {
dataSource.sourceFilename = customFileName
} else {
// Ignore the filename for URLs.
dataSource.sourceFilename = url.lastPathComponent
}
return dataSource
}
}
private class func preferredItemProviders(inputItem: NSExtensionItem) -> [NSItemProvider]? {
guard let attachments = inputItem.attachments else {
return nil
}
var visualMediaItemProviders = [NSItemProvider]()
var hasNonVisualMedia = false
for attachment in attachments {
if isVisualMediaItem(itemProvider: attachment) {
visualMediaItemProviders.append(attachment)
} else {
hasNonVisualMedia = true
}
}
// Only allow multiple-attachment sends if all attachments
// are visual media.
if visualMediaItemProviders.count > 0 && !hasNonVisualMedia {
return visualMediaItemProviders
}
// 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.
// Prefer a URL provider if available
if let preferredAttachment = attachments.first(where: { (attachment: Any) -> Bool in
guard let itemProvider = attachment as? NSItemProvider else {
return false
}
return isUrlItem(itemProvider: itemProvider)
}) {
return [preferredAttachment]
}
// else return whatever is available
if let itemProvider = inputItem.attachments?.first {
return [itemProvider]
} else {
owsFailDebug("Missing attachment.")
}
return []
}
private func selectItemProviders() -> Promise<[NSItemProvider]> {
guard let inputItems = self.extensionContext?.inputItems else {
let error = ShareViewControllerError.assertionError(description: "no input item")
return Promise(error: error)
}
for inputItemRaw in inputItems {
guard let inputItem = inputItemRaw as? NSExtensionItem else {
Logger.error("invalid inputItem \(inputItemRaw)")
continue
}
if let itemProviders = ShareVC.preferredItemProviders(inputItem: inputItem) {
return Promise.value(itemProviders)
}
}
let error = ShareViewControllerError.assertionError(description: "no input item")
return Promise(error: error)
}
private
struct LoadedItem {
let itemProvider: NSItemProvider
let itemUrl: URL
let utiType: String
var customFileName: String?
var isConvertibleToTextMessage = false
var isConvertibleToContactShare = false
init(itemProvider: NSItemProvider,
itemUrl: URL,
utiType: String,
customFileName: String? = nil,
isConvertibleToTextMessage: Bool = false,
isConvertibleToContactShare: Bool = false) {
self.itemProvider = itemProvider
self.itemUrl = itemUrl
self.utiType = utiType
self.customFileName = customFileName
self.isConvertibleToTextMessage = isConvertibleToTextMessage
self.isConvertibleToContactShare = isConvertibleToContactShare
}
}
private func loadItemProvider(itemProvider: NSItemProvider) -> Promise<LoadedItem> {
Logger.info("attachment: \(itemProvider)")
// We need to be very careful about which UTI type we use.
//
// * In the case of "textual" shares (e.g. web URLs and text snippets), we want to
// coerce the UTI type to kUTTypeURL or kUTTypeText.
// * We want to treat shared files as file attachments. Therefore we do not
// want to treat file URLs like web URLs.
// * UTIs aren't very descriptive (there are far more MIME types than UTI types)
// so in the case of file attachments we try to refine the attachment type
// using the file extension.
guard let srcUtiType = ShareVC.utiType(itemProvider: itemProvider) else {
let error = ShareViewControllerError.unsupportedMedia
return Promise(error: error)
}
Logger.debug("matched utiType: \(srcUtiType)")
let (promise, resolver) = Promise<LoadedItem>.pending()
let loadCompletion: NSItemProvider.CompletionHandler = { [weak self]
(value, error) in
guard let _ = self else { return }
guard error == nil else {
resolver.reject(error!)
return
}
guard let value = value else {
let missingProviderError = ShareViewControllerError.assertionError(description: "missing item provider")
resolver.reject(missingProviderError)
return
}
Logger.info("value type: \(type(of: value))")
if let data = value as? Data {
let customFileName = "Contact.vcf"
let customFileExtension = MIMETypeUtil.fileExtension(forUTIType: srcUtiType)
guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: customFileExtension) else {
let writeError = ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")
resolver.reject(writeError)
return
}
let fileUrl = URL(fileURLWithPath: tempFilePath)
resolver.fulfill(LoadedItem(itemProvider: itemProvider,
itemUrl: fileUrl,
utiType: srcUtiType,
customFileName: customFileName,
isConvertibleToContactShare: false))
} else if let string = value as? String {
Logger.debug("string provider: \(string)")
guard let data = string.filterStringForDisplay().data(using: String.Encoding.utf8) else {
let writeError = ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")
resolver.reject(writeError)
return
}
guard let tempFilePath = OWSFileSystem.writeData(toTemporaryFile: data, fileExtension: "txt") else {
let writeError = ShareViewControllerError.assertionError(description: "Error writing item data: \(String(describing: error))")
resolver.reject(writeError)
return
}
let fileUrl = URL(fileURLWithPath: tempFilePath)
let isConvertibleToTextMessage = !itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String)
if UTTypeConformsTo(srcUtiType as CFString, kUTTypeText) {
resolver.fulfill(LoadedItem(itemProvider: itemProvider,
itemUrl: fileUrl,
utiType: srcUtiType,
isConvertibleToTextMessage: isConvertibleToTextMessage))
} else {
resolver.fulfill(LoadedItem(itemProvider: itemProvider,
itemUrl: fileUrl,
utiType: kUTTypeText as String,
isConvertibleToTextMessage: isConvertibleToTextMessage))
}
} else if let url = value as? URL {
// If the share itself is a URL (e.g. a link from Safari), try to send this as a text message.
let isConvertibleToTextMessage = (itemProvider.registeredTypeIdentifiers.contains(kUTTypeURL as String) &&
!itemProvider.registeredTypeIdentifiers.contains(kUTTypeFileURL as String))
if isConvertibleToTextMessage {
resolver.fulfill(LoadedItem(itemProvider: itemProvider,
itemUrl: url,
utiType: kUTTypeURL as String,
isConvertibleToTextMessage: isConvertibleToTextMessage))
} else {
resolver.fulfill(LoadedItem(itemProvider: itemProvider,
itemUrl: url,
utiType: srcUtiType,
isConvertibleToTextMessage: isConvertibleToTextMessage))
}
} else if let image = value as? UIImage {
if let data = image.pngData() {
let tempFilePath = OWSFileSystem.temporaryFilePath(withFileExtension: "png")
do {
let url = NSURL.fileURL(withPath: tempFilePath)
try data.write(to: url)
resolver.fulfill(LoadedItem(itemProvider: itemProvider, itemUrl: url,
utiType: srcUtiType))
} catch {
resolver.reject(ShareViewControllerError.assertionError(description: "couldn't write UIImage: \(String(describing: error))"))
}
} else {
resolver.reject(ShareViewControllerError.assertionError(description: "couldn't convert UIImage to PNG: \(String(describing: error))"))
}
} else {
// It's unavoidable that we may sometimes receives data types that we
// don't know how to handle.
let unexpectedTypeError = ShareViewControllerError.assertionError(description: "unexpected value: \(String(describing: value))")
resolver.reject(unexpectedTypeError)
}
}
itemProvider.loadItem(forTypeIdentifier: srcUtiType, options: nil, completionHandler: loadCompletion)
return promise
}
private func buildAttachment(forLoadedItem loadedItem: LoadedItem) -> Promise<SignalAttachment> {
let itemProvider = loadedItem.itemProvider
let itemUrl = loadedItem.itemUrl
let utiType = loadedItem.utiType
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)
}
Logger.debug("building DataSource with url: \(url), utiType: \(utiType)")
guard let dataSource = ShareVC.createDataSource(utiType: utiType, url: url, customFileName: loadedItem.customFileName) else {
let error = ShareViewControllerError.assertionError(description: "Unable to read attachment data")
return Promise(error: error)
}
// start with base utiType, but it might be something generic like "image"
var specificUTIType = utiType
if utiType == (kUTTypeURL as String) {
// Use kUTTypeURL for URLs.
} else if UTTypeConformsTo(utiType as CFString, kUTTypeText) {
// Use kUTTypeText for text.
} else if url.pathExtension.count > 0 {
// Determine a more specific utiType based on file extension
if let typeExtension = MIMETypeUtil.utiType(forFileExtension: url.pathExtension) {
Logger.debug("utiType based on extension: \(typeExtension)")
specificUTIType = typeExtension
}
}
guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, dataUTI: specificUTIType) else {
// This can happen, e.g. when sharing a quicktime-video from iCloud drive.
let (promise, _) = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: specificUTIType)
return promise
}
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: specificUTIType, imageQuality: .medium)
if loadedItem.isConvertibleToContactShare {
Logger.info("isConvertibleToContactShare")
attachment.isConvertibleToContactShare = true
} else if loadedItem.isConvertibleToTextMessage {
Logger.info("isConvertibleToTextMessage")
attachment.isConvertibleToTextMessage = true
}
return Promise.value(attachment)
}
private func buildAttachments() -> Promise<[SignalAttachment]> {
return selectItemProviders().then { [weak self] (itemProviders) -> Promise<[SignalAttachment]> in
guard let strongSelf = self else {
let error = ShareViewControllerError.assertionError(description: "expired")
return Promise(error: error)
}
var loadPromises = [Promise<SignalAttachment>]()
for itemProvider in itemProviders.prefix(SignalAttachment.maxAttachmentsAllowed) {
let loadPromise = strongSelf.loadItemProvider(itemProvider: itemProvider)
.then({ (loadedItem) -> Promise<SignalAttachment> in
return strongSelf.buildAttachment(forLoadedItem: loadedItem)
})
loadPromises.append(loadPromise)
}
return when(fulfilled: loadPromises)
}.map { (signalAttachments) -> [SignalAttachment] in
guard signalAttachments.count > 0 else {
let error = ShareViewControllerError.assertionError(description: "no valid attachments")
throw error
}
return signalAttachments
}
}
// 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 app.
return !itemProvider.registeredTypeIdentifiers.contains(kUTTypeMPEG4 as String)
}
}