diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 3c480ff54..9aa3e60a5 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -91,7 +91,7 @@ 346129FF1FD5F31400532771 /* OWS103EnableVideoCalling.m in Sources */ = {isa = PBXBuildFile; fileRef = 346129F21FD5F31400532771 /* OWS103EnableVideoCalling.m */; }; 34612A001FD5F31400532771 /* OWS105AttachmentFilePaths.h in Headers */ = {isa = PBXBuildFile; fileRef = 346129F31FD5F31400532771 /* OWS105AttachmentFilePaths.h */; }; 34612A011FD5F31400532771 /* OWS104CreateRecipientIdentities.h in Headers */ = {isa = PBXBuildFile; fileRef = 346129F41FD5F31400532771 /* OWS104CreateRecipientIdentities.h */; }; - 34612A061FD7238600532771 /* OWSContactsSyncing.h in Headers */ = {isa = PBXBuildFile; fileRef = 34612A041FD7238500532771 /* OWSContactsSyncing.h */; }; + 34612A061FD7238600532771 /* OWSContactsSyncing.h in Headers */ = {isa = PBXBuildFile; fileRef = 34612A041FD7238500532771 /* OWSContactsSyncing.h */; settings = {ATTRIBUTES = (Public, ); }; }; 34612A071FD7238600532771 /* OWSContactsSyncing.m in Sources */ = {isa = PBXBuildFile; fileRef = 34612A051FD7238500532771 /* OWSContactsSyncing.m */; }; 346B66311F4E29B200E5122F /* CropScaleImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346B66301F4E29B200E5122F /* CropScaleImageViewController.swift */; }; 3471B1DA1EB7C63600F6AEC8 /* NewNonContactConversationViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3471B1D91EB7C63600F6AEC8 /* NewNonContactConversationViewController.m */; }; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index 28ae423b7..c1e879fea 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -62,6 +62,7 @@ #import #import #import +#import #import #import #import @@ -2482,6 +2483,14 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { } [dataSource setSourceFilename:filename]; + + // Although we want to be able to send higher quality attachments throught the document picker + // it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov) + if ([SignalAttachment isInvalidVideoWithDataSource:dataSource dataUTI:type]) { + [self sendQualityAdjustedAttachmentForVideo:url filename:filename skipApprovalDialog:NO]; + return; + } + // "Document picker" attachments _SHOULD NOT_ be resized, if possible. SignalAttachment *attachment = [SignalAttachment attachmentWithDataSource:dataSource dataUTI:type imageQuality:TSImageQualityOriginal]; @@ -2722,50 +2731,33 @@ typedef NS_ENUM(NSInteger, MessagesRangeSizeMode) { presentFromViewController:self canCancel:YES backgroundBlock:^(ModalActivityIndicatorViewController *modalActivityIndicator) { - AVAsset *video = [AVAsset assetWithURL:movieURL]; - AVAssetExportSession *exportSession = - [AVAssetExportSession exportSessionWithAsset:video - presetName:AVAssetExportPresetMediumQuality]; - exportSession.shouldOptimizeForNetworkUse = YES; - exportSession.outputFileType = AVFileTypeMPEG4; - NSURL *compressedVideoUrl = [[self videoTempFolder] - URLByAppendingPathComponent:[[[NSUUID UUID] UUIDString] - stringByAppendingPathExtension:@"mp4"]]; - exportSession.outputURL = compressedVideoUrl; - [exportSession exportAsynchronouslyWithCompletionHandler:^{ - dispatch_async(dispatch_get_main_queue(), ^{ - OWSAssert([NSThread isMainThread]); - - if (modalActivityIndicator.wasCancelled) { - return; + DataSource *dataSource = [DataSourcePath dataSourceWithURL:movieURL]; + dataSource.sourceFilename = filename; + VideoCompressionResult *compressionResult = + [SignalAttachment compressVideoAsMp4WithDataSource:dataSource + dataUTI:(NSString *)kUTTypeMPEG4]; + [compressionResult.attachmentPromise retainUntilComplete]; + + compressionResult.attachmentPromise.then(^(SignalAttachment *attachment) { + OWSAssert([NSThread isMainThread]); + OWSAssert([attachment isKindOfClass:[SignalAttachment class]]); + + if (modalActivityIndicator.wasCancelled) { + return; + } + + [modalActivityIndicator dismissWithCompletion:^{ + if (!attachment || [attachment hasError]) { + DDLogError(@"%@ %s Invalid attachment: %@.", + self.logTag, + __PRETTY_FUNCTION__, + attachment ? [attachment errorName] : @"Missing data"); + [self showErrorAlertForAttachment:attachment]; + } else { + [self tryToSendAttachmentIfApproved:attachment skipApprovalDialog:skipApprovalDialog]; } - - [modalActivityIndicator dismissWithCompletion:^{ - - NSString *baseFilename = filename.stringByDeletingPathExtension; - NSString *mp4Filename = [baseFilename stringByAppendingPathExtension:@"mp4"]; - DataSource *_Nullable dataSource = - [DataSourcePath dataSourceWithURL:compressedVideoUrl]; - [dataSource setSourceFilename:mp4Filename]; - - // Remove temporary file when complete. - [dataSource setShouldDeleteOnDeallocation]; - SignalAttachment *attachment = - [SignalAttachment attachmentWithDataSource:dataSource - dataUTI:(NSString *)kUTTypeMPEG4]; - if (!attachment || [attachment hasError]) { - DDLogError(@"%@ %s Invalid attachment: %@.", - self.logTag, - __PRETTY_FUNCTION__, - attachment ? [attachment errorName] : @"Missing data"); - [self showErrorAlertForAttachment:attachment]; - } else { - [self tryToSendAttachmentIfApproved:attachment - skipApprovalDialog:skipApprovalDialog]; - } - }]; - }); - }]; + }]; + }); }]; } diff --git a/SignalMessaging/attachments/MediaMessageView.swift b/SignalMessaging/attachments/MediaMessageView.swift index 7bee41fcc..5360f329c 100644 --- a/SignalMessaging/attachments/MediaMessageView.swift +++ b/SignalMessaging/attachments/MediaMessageView.swift @@ -464,11 +464,21 @@ public class MediaMessageView: UIView, OWSAudioAttachmentPlayerDelegate { @objc public func playVideo() { guard let dataUrl = attachment.dataUrl else { + owsFail("\(self.logTag) attachment is missing dataUrl") return } + + let filePath = dataUrl.path + guard FileManager.default.fileExists(atPath: filePath) else { + owsFail("\(self.logTag) file at \(filePath) doesn't exist") + return + } + guard let videoPlayer = MPMoviePlayerController(contentURL: dataUrl) else { + owsFail("\(self.logTag) unable to build moview player controller") return } + videoPlayer.prepareToPlay() NotificationCenter.default.addObserver(forName: .MPMoviePlayerWillExitFullscreen, object: nil, queue: nil) { [weak self] _ in diff --git a/SignalMessaging/attachments/SignalAttachment.swift b/SignalMessaging/attachments/SignalAttachment.swift index 72b0434e5..e410aaf87 100644 --- a/SignalMessaging/attachments/SignalAttachment.swift +++ b/SignalMessaging/attachments/SignalAttachment.swift @@ -5,6 +5,7 @@ import Foundation import MobileCoreServices import SignalServiceKit +import PromiseKit import AVFoundation enum SignalAttachmentError: Error { @@ -13,6 +14,7 @@ enum SignalAttachmentError: Error { case invalidData case couldNotParseImage case couldNotConvertToJpeg + case couldNotConvertToMpeg4 case invalidFileFormat } @@ -49,6 +51,8 @@ extension SignalAttachmentError: LocalizedError { return NSLocalizedString("ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_JPEG", comment: "Attachment error message for image attachments which could not be converted to JPEG") case .invalidFileFormat: return NSLocalizedString("ATTACHMENT_ERROR_INVALID_FILE_FORMAT", comment: "Attachment error message for attachments with an invalid file format") + case .couldNotConvertToMpeg4: + return NSLocalizedString("ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_MP4", comment: "Attachment error message for video attachments which could not be converted to MP4") } } } @@ -236,7 +240,13 @@ public class SignalAttachment: NSObject { } do { - let asset = AVURLAsset(url:mediaUrl) + let filePath = mediaUrl.path + guard FileManager.default.fileExists(atPath: filePath) else { + owsFail("asset at \(filePath) doesn't exist") + return nil + } + + let asset = AVURLAsset(url: mediaUrl) let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true let cgImage = try generator.copyCGImage(at: CMTimeMake(0, 1), actualTime: nil) @@ -353,6 +363,10 @@ public class SignalAttachment: NSObject { return MIMETypeUtil.supportedImageUTITypes().union(animatedImageUTISet) } + private class var outputVideoUTISet: Set { + return Set([kUTTypeMPEG4 as String]) + } + // Returns the set of UTIs that correspond to valid animated image formats // for Signal attachments. private class var animatedImageUTISet: Set { @@ -568,7 +582,7 @@ public class SignalAttachment: NSObject { } attachment.cachedImage = image - if isInputImageValidOutputImage(image: image, dataSource: dataSource, dataUTI: dataUTI, imageQuality:imageQuality) { + if isValidOutputImage(image: image, dataSource: dataSource, dataUTI: dataUTI, imageQuality:imageQuality) { if let sourceFilename = dataSource.sourceFilename, let sourceFileExtension = sourceFilename.fileExtension, ["heic", "heif"].contains(sourceFileExtension.lowercased()) { @@ -599,7 +613,7 @@ public class SignalAttachment: NSObject { // If the proposed attachment already conforms to the // file size and content size limits, don't recompress it. - private class func isInputImageValidOutputImage(image: UIImage?, dataSource: DataSource?, dataUTI: String, imageQuality: TSImageQuality) -> Bool { + private class func isValidOutputImage(image: UIImage?, dataSource: DataSource?, dataUTI: String, imageQuality: TSImageQuality) -> Bool { guard let image = image else { return false } @@ -767,10 +781,138 @@ public class SignalAttachment: NSObject { // NOTE: The attachment returned by this method may not be valid. // Check the attachment's error property. private class func videoAttachment(dataSource: DataSource?, dataUTI: String) -> SignalAttachment { - return newAttachment(dataSource : dataSource, - dataUTI : dataUTI, - validUTISet : videoUTISet, - maxFileSize : kMaxFileSizeVideo) + guard let dataSource = dataSource else { + let dataSource = DataSourceValue.emptyDataSource() + let attachment = SignalAttachment(dataSource:dataSource, dataUTI: dataUTI) + attachment.error = .missingData + return attachment + } + + if !isValidOutputVideo(dataSource: dataSource, dataUTI: dataUTI) { + owsFail("building video with invalid output, migrate to async API using compressVideoAsMp4") + } + + return newAttachment(dataSource: dataSource, + dataUTI: dataUTI, + validUTISet: videoUTISet, + maxFileSize: kMaxFileSizeVideo) + } + + public class func copyToVideoTempDir(url fromUrl: URL) throws -> URL { + let baseDir = SignalAttachment.videoTempPath.appendingPathComponent(UUID().uuidString, isDirectory: true) + OWSFileSystem.ensureDirectoryExists(baseDir.path) + let toUrl = baseDir.appendingPathComponent(fromUrl.lastPathComponent) + + Logger.debug("\(self.logTag) moving \(fromUrl) -> \(toUrl)") + try FileManager.default.copyItem(at: fromUrl, to: toUrl) + + return toUrl + } + + private class var videoTempPath: URL { + let videoDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("video") + OWSFileSystem.ensureDirectoryExists(videoDir.path) + return videoDir + } + + public class func compressVideoAsMp4(dataSource: DataSource, dataUTI: String) -> (Promise, AVAssetExportSession?) { + Logger.debug("\(self.TAG) in \(#function)") + + guard let url = dataSource.dataUrl() else { + let attachment = SignalAttachment(dataSource : DataSourceValue.emptyDataSource(), dataUTI: dataUTI) + attachment.error = .missingData + return (Promise(value: attachment), nil) + } + + let asset = AVAsset(url: url) + + guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else { + let attachment = SignalAttachment(dataSource : DataSourceValue.emptyDataSource(), dataUTI: dataUTI) + attachment.error = .couldNotConvertToMpeg4 + return (Promise(value: attachment), nil) + } + + exportSession.shouldOptimizeForNetworkUse = true + exportSession.outputFileType = AVFileTypeMPEG4 + + let exportURL = videoTempPath.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4") + exportSession.outputURL = exportURL + + let (promise, fulfill, _) = Promise.pending() + + Logger.debug("\(self.TAG) starting video export") + exportSession.exportAsynchronously { + Logger.debug("\(self.TAG) Completed video export") + let baseFilename = dataSource.sourceFilename + let mp4Filename = baseFilename?.filenameWithoutExtension.appendingFileExtension("mp4") + + guard let dataSource = DataSourcePath.dataSource(with: exportURL) else { + owsFail("Failed to build data source for exported video URL") + let attachment = SignalAttachment(dataSource : DataSourceValue.emptyDataSource(), dataUTI: dataUTI) + attachment.error = .couldNotConvertToMpeg4 + fulfill(attachment) + return + } + + dataSource.setShouldDeleteOnDeallocation() + dataSource.sourceFilename = mp4Filename + + let attachment = SignalAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String) + fulfill(attachment) + } + + return (promise, exportSession) + } + + @objc + public class VideoCompressionResult: NSObject { + @objc + public let attachmentPromise: AnyPromise + + @objc + public let exportSession: AVAssetExportSession? + + fileprivate init(attachmentPromise: Promise, exportSession: AVAssetExportSession?) { + self.attachmentPromise = AnyPromise(attachmentPromise) + self.exportSession = exportSession + super.init() + } + } + + @objc + public class func compressVideoAsMp4(dataSource: DataSource, dataUTI: String) -> VideoCompressionResult { + let (attachmentPromise, exportSession) = compressVideoAsMp4(dataSource: dataSource, dataUTI: dataUTI) + return VideoCompressionResult(attachmentPromise: attachmentPromise, exportSession: exportSession) + } + + public class func isInvalidVideo(dataSource: DataSource, dataUTI: String) -> Bool { + guard videoUTISet.contains(dataUTI) else { + // not a video + return false + } + + guard isValidOutputVideo(dataSource: dataSource, dataUTI: dataUTI) else { + // found a video which needs to be converted + return true + } + + // It is a video, but it's not invalid + return false + } + + private class func isValidOutputVideo(dataSource: DataSource?, dataUTI: String) -> Bool { + guard let dataSource = dataSource else { + return false + } + + guard SignalAttachment.outputVideoUTISet.contains(dataUTI) else { + return false + } + + if dataSource.dataLength() <= kMaxFileSizeVideo { + return true + } + return false } // MARK: Audio Attachments diff --git a/SignalMessaging/categories/Promise+retainUntilComplete.swift b/SignalMessaging/categories/Promise+retainUntilComplete.swift index d3a74a61a..8f45c95c6 100644 --- a/SignalMessaging/categories/Promise+retainUntilComplete.swift +++ b/SignalMessaging/categories/Promise+retainUntilComplete.swift @@ -4,6 +4,21 @@ import PromiseKit +public extension AnyPromise { + /** + * Sometimes there isn't a straight forward candidate to retain a promise, in that case we tell the + * promise to self retain, until it completes to avoid the risk it's GC'd before completion. + */ + func retainUntilComplete() { + // Unfortunately, there is (currently) no way to surpress the + // compiler warning: "Variable 'retainCycle' was written to, but never read" + var retainCycle: AnyPromise? = self + self.always { + retainCycle = nil + } + } +} + public extension Promise { /** * Sometimes there isn't a straight forward candidate to retain a promise, in that case we tell the diff --git a/SignalServiceKit/src/Util/MIMETypeUtil.m b/SignalServiceKit/src/Util/MIMETypeUtil.m index e779d2dbb..ff9f7e0de 100644 --- a/SignalServiceKit/src/Util/MIMETypeUtil.m +++ b/SignalServiceKit/src/Util/MIMETypeUtil.m @@ -211,19 +211,20 @@ NSString *const kSyncMessageFileExtension = @"bin"; } + (BOOL)isSupportedVideoFile:(NSString *)filePath { - return [[self supportedVideoExtensionTypesToMIMETypes] objectForKey:[filePath pathExtension]] != nil; + return [[self supportedVideoExtensionTypesToMIMETypes] objectForKey:filePath.pathExtension.lowercaseString] != nil; } + (BOOL)isSupportedAudioFile:(NSString *)filePath { - return [[self supportedAudioExtensionTypesToMIMETypes] objectForKey:[filePath pathExtension]] != nil; + return [[self supportedAudioExtensionTypesToMIMETypes] objectForKey:filePath.pathExtension.lowercaseString] != nil; } + (BOOL)isSupportedImageFile:(NSString *)filePath { - return [[self supportedImageExtensionTypesToMIMETypes] objectForKey:[filePath pathExtension]] != nil; + return [[self supportedImageExtensionTypesToMIMETypes] objectForKey:filePath.pathExtension.lowercaseString] != nil; } + (BOOL)isSupportedAnimatedFile:(NSString *)filePath { - return [[self supportedAnimatedExtensionTypesToMIMETypes] objectForKey:[filePath pathExtension]] != nil; + return + [[self supportedAnimatedExtensionTypesToMIMETypes] objectForKey:filePath.pathExtension.lowercaseString] != nil; } + (nullable NSString *)getSupportedExtensionFromVideoMIMEType:(NSString *)supportedMIMEType diff --git a/SignalShareExtension/SAELoadViewController.swift b/SignalShareExtension/SAELoadViewController.swift index 9857d5e93..651b85733 100644 --- a/SignalShareExtension/SAELoadViewController.swift +++ b/SignalShareExtension/SAELoadViewController.swift @@ -10,7 +10,36 @@ class SAELoadViewController: UIViewController { weak var delegate: ShareViewDelegate? - var activityIndicator: UIActivityIndicatorView? + var activityIndicator: UIActivityIndicatorView! + var progressView: UIProgressView! + + var progress: Progress? { + didSet { + guard progressView != nil else { + return + } + + updateProgressViewVisability() + progressView.observedProgress = progress + } + } + + func updateProgressViewVisability() { + guard progressView != nil, activityIndicator != nil else { + return + } + + // Prefer to show progress view when progress is present + if self.progress == nil { + activityIndicator.startAnimating() + self.progressView.isHidden = true + self.activityIndicator.isHidden = false + } else { + activityIndicator.stopAnimating() + self.progressView.isHidden = false + self.activityIndicator.isHidden = true + } + } // MARK: Initializers and Factory Methods @@ -39,6 +68,16 @@ class SAELoadViewController: UIViewController { self.view.addSubview(activityIndicator) activityIndicator.autoCenterInSuperview() + progressView = UIProgressView(progressViewStyle: .default) + progressView.observedProgress = progress + + self.view.addSubview(progressView) + progressView.autoVCenterInSuperview() + progressView.autoPinWidthToSuperview(withMargin: ScaleFromIPhone5(30)) + progressView.progressTintColor = UIColor.white + + updateProgressViewVisability() + let label = UILabel() label.textColor = UIColor.white label.font = UIFont.ows_mediumFont(withSize: 18) @@ -53,20 +92,11 @@ class SAELoadViewController: UIViewController { super.viewWillAppear(animated) self.navigationController?.isNavigationBarHidden = false - - guard let activityIndicator = activityIndicator else { - return - } - activityIndicator.startAnimating() } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - guard let activityIndicator = activityIndicator else { - return - } - activityIndicator.stopAnimating() } // MARK: - Event Handlers diff --git a/SignalShareExtension/ShareViewController.swift b/SignalShareExtension/ShareViewController.swift index 55a3a8ddb..090e5b942 100644 --- a/SignalShareExtension/ShareViewController.swift +++ b/SignalShareExtension/ShareViewController.swift @@ -15,21 +15,15 @@ public class ShareViewController: UINavigationController, ShareViewDelegate, SAE private var hasInitialRootViewController = false private var isReadyForAppExtensions = false - var loadViewController: SAELoadViewController! + private var progressPoller: ProgressPoller? + var loadViewController: SAELoadViewController? + + let shareViewNavigationController: UINavigationController = UINavigationController() override open func loadView() { super.loadView() - Logger.debug("\(self.logTag) \(#function)") - // We can't show the conversation picker until the DB is set up. - // Normally this will only take a moment, so rather than flickering and then hiding the loading screen - // We start as invisible, and only fade it in if it's going to take a while - self.view.alpha = 0 - UIView.animate(withDuration: 0.1, delay: 0.5, options: [.curveEaseInOut], animations: { - self.view.alpha = 1 - }, completion: nil) - // This should be the first thing we do. let appContext = ShareAppExtensionContext(rootViewController:self) SetCurrentAppContext(appContext) @@ -63,7 +57,7 @@ public class ShareViewController: UINavigationController, ShareViewDelegate, SAE // most of the singletons, etc.). We just want to show an error view and // abort. isReadyForAppExtensions = OWSPreferences.isReadyForAppExtensions() - if !isReadyForAppExtensions { + guard isReadyForAppExtensions else { // If we don't have TSSStorageManager, we can't consult TSAccountManager // for isRegistered, so we use OWSPreferences which is usually-accurate // copy of that state. @@ -75,6 +69,20 @@ public class ShareViewController: UINavigationController, ShareViewDelegate, SAE return } + let loadViewController = SAELoadViewController(delegate: self) + self.loadViewController = loadViewController + + // Don't display load screen immediately, in hopes that we can avoid it altogether. + after(seconds: 0.5).then { () -> Void in + guard self.presentedViewController == nil else { + Logger.debug("\(self.logTag) setup completed quickly, no need to present load view controller.") + return + } + + Logger.debug("\(self.logTag) setup is slow - showing loading screen") + self.showPrimaryViewController(loadViewController) + }.retainUntilComplete() + // We shouldn't set up our environment until after we've consulted isReadyForAppExtensions. AppSetup.setupEnvironment({ return NoopCallMessageHandler() @@ -86,8 +94,6 @@ public class ShareViewController: UINavigationController, ShareViewDelegate, SAE // upgrade process may depend on Environment. VersionMigrations.performUpdateCheck() - self.loadViewController = SAELoadViewController(delegate:self) - self.pushViewController(loadViewController, animated: false) self.isNavigationBarHidden = true // We don't need to use "screen protection" in the SAE. @@ -110,6 +116,7 @@ public class ShareViewController: UINavigationController, ShareViewDelegate, SAE } deinit { + Logger.info("\(self.logTag) dealloc") NotificationCenter.default.removeObserver(self) } @@ -290,15 +297,8 @@ public class ShareViewController: UINavigationController, ShareViewDelegate, SAE } private func showErrorView(title: String, message: String) { - // ensure view is visible. - self.view.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1, delay: 0, options: [.curveEaseInOut], animations: { - - self.view.alpha = 1 - }, completion: nil) - let viewController = SAEFailedViewController(delegate:self, title:title, message:message) - self.setViewControllers([viewController], animated: false) + self.showPrimaryViewController(viewController) } // MARK: View Lifecycle @@ -363,20 +363,32 @@ public class ShareViewController: UINavigationController, ShareViewDelegate, SAE // MARK: Helpers - private func presentConversationPicker() { - // pause any animation revealing the "loading" screen - self.view.layer.removeAllAnimations() - - // Once we've presented the conversation picker, we hide the loading VC - // so that it's not revealed when we eventually dismiss the share extension. - loadViewController.view.isHidden = true + // 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) { + shareViewNavigationController.setViewControllers([viewController], animated: false) + if self.presentedViewController == nil { + Logger.debug("\(self.logTag) presenting modally: \(viewController)") + self.present(shareViewNavigationController, animated: true) + } else { + Logger.debug("\(self.logTag) modal already presented. swapping modal content for: \(viewController)") + assert(self.presentedViewController == shareViewNavigationController) + } + } + private func presentConversationPicker() { self.buildAttachment().then { attachment -> Void in let conversationPicker = SharingThreadPickerViewController(shareViewDelegate: self) - let navigationController = UINavigationController(rootViewController: conversationPicker) - navigationController.isNavigationBarHidden = true conversationPicker.attachment = attachment - self.present(navigationController, animated: true, completion: nil) + self.shareViewNavigationController.isNavigationBarHidden = true + self.progressPoller = nil + self.loadViewController = nil + self.showPrimaryViewController(conversationPicker) Logger.info("showing picker with attachment: \(attachment)") }.catch { error in 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.") @@ -448,7 +460,18 @@ public class ShareViewController: UINavigationController, ShareViewDelegate, SAE // TODO accept other data types // TODO whitelist attachment types // TODO coerce when necessary and possible - return promise.then { (url: URL) -> SignalAttachment in + return promise.then { (itemUrl: URL) -> Promise in + + let url: URL = try { + if self.isVideoNeedingRelocation(itemProvider: itemProvider, itemUrl: itemUrl) { + return try SignalAttachment.copyToVideoTempDir(url: itemUrl) + } else { + return itemUrl + } + }() + + Logger.debug("\(self.logTag) building DataSource with url: \(url)") + guard let dataSource = DataSourcePath.dataSource(with: url) else { throw ShareViewControllerError.assertionError(description: "Unable to read attachment data") } @@ -459,13 +482,114 @@ public class ShareViewController: UINavigationController, ShareViewDelegate, SAE if url.pathExtension.count > 0 { // Determine a more specific utiType based on file extension if let typeExtension = MIMETypeUtil.utiType(forFileExtension: url.pathExtension) { + Logger.debug("\(self.logTag) utiType based on extension: \(typeExtension)") specificUTIType = typeExtension } } - let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: specificUTIType, imageQuality:.medium) + guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, dataUTI: specificUTIType) else { + // This can happen, e.g. when sharing a quicktime-video from iCloud drive. + + let (promise, exportSession) = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: specificUTIType) + + // 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 { + let progressPoller = ProgressPoller(timeInterval: 0.1, ratioCompleteBlock: { return exportSession.progress }) + self.progressPoller = progressPoller + progressPoller.startPolling() + + guard let loadViewController = self.loadViewController else { + owsFail("load view controller was unexpectedly nil") + return promise + } + + loadViewController.progress = progressPoller.progress + } + + return promise + } + + let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: specificUTIType, imageQuality: .medium) + 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 { + guard MIMETypeUtil.utiType(forFileExtension: itemUrl.pathExtension) == 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) + } +} + +// Exposes a Progress object, whose progress is updated by polling the return of a given block +private class ProgressPoller { + + let TAG = "[ProgressPoller]" + + 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 { + owsFail("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 - return attachment + if completedUnitCount == strongSelf.progressTotalUnitCount { + Logger.debug("\(strongSelf.TAG) progress complete") + timer.invalidate() + } } } } diff --git a/SignalShareExtension/SignalShareExtension-Bridging-Header.h b/SignalShareExtension/SignalShareExtension-Bridging-Header.h index 42008bc60..cdad2cb66 100644 --- a/SignalShareExtension/SignalShareExtension-Bridging-Header.h +++ b/SignalShareExtension/SignalShareExtension-Bridging-Header.h @@ -6,19 +6,19 @@ #import // Separate iOS Frameworks from other imports. -#import "DebugLogger.h" -#import "Environment.h" -#import "OWSContactsManager.h" -#import "OWSContactsSyncing.h" -#import "OWSLogger.h" -#import "OWSMath.h" -#import "OWSPreferences.h" -#import "Release.h" #import "ShareAppExtensionContext.h" -#import "UIColor+OWS.h" -#import "UIFont+OWS.h" -#import "UIView+OWS.h" -#import "VersionMigrations.h" +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import #import #import #import