Merge branch 'mkirk/convert-video'

pull/1/head
Michael Kirk 8 years ago
commit 26c76e6a0c

@ -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 */; };

@ -62,6 +62,7 @@
#import <JSQSystemSoundPlayer/JSQSystemSoundPlayer.h>
#import <MediaPlayer/MediaPlayer.h>
#import <MobileCoreServices/UTCoreTypes.h>
#import <PromiseKit/AnyPromise.h>
#import <SignalMessaging/OWSContactOffersInteraction.h>
#import <SignalMessaging/OWSFormat.h>
#import <SignalMessaging/OWSUserProfile.h>
@ -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];
}
}];
});
}];
}];
});
}];
}

@ -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

@ -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<String> {
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<String> {
@ -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<SignalAttachment>, 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<SignalAttachment>.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<SignalAttachment>, 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

@ -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

@ -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

@ -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

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

@ -6,19 +6,19 @@
#import <UIKit/UIKit.h>
// 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 <SignalMessaging/DebugLogger.h>
#import <SignalMessaging/Environment.h>
#import <SignalMessaging/OWSContactsManager.h>
#import <SignalMessaging/OWSContactsSyncing.h>
#import <SignalMessaging/OWSLogger.h>
#import <SignalMessaging/OWSMath.h>
#import <SignalMessaging/OWSPreferences.h>
#import <SignalMessaging/Release.h>
#import <SignalMessaging/UIColor+OWS.h>
#import <SignalMessaging/UIFont+OWS.h>
#import <SignalMessaging/UIView+OWS.h>
#import <SignalMessaging/VersionMigrations.h>
#import <SignalServiceKit/AppContext.h>
#import <SignalServiceKit/AppVersion.h>
#import <SignalServiceKit/Asserts.h>

Loading…
Cancel
Save