From ec595f53d0669819e02539320ad29a764fec48fd Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 10 Mar 2017 10:46:36 -0300 Subject: [PATCH 01/11] Gather attachment-related logic in SignalAttachment class. // FREEBIE --- .../view controllers/SignalAttachment.swift | 417 ++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 Signal/src/view controllers/SignalAttachment.swift diff --git a/Signal/src/view controllers/SignalAttachment.swift b/Signal/src/view controllers/SignalAttachment.swift new file mode 100644 index 0000000000..7bea57db4e --- /dev/null +++ b/Signal/src/view controllers/SignalAttachment.swift @@ -0,0 +1,417 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import Foundation +import MobileCoreServices + +enum SignalAttachmentError: String { + case fileSizeTooLarge + case invalidData + case couldNotParseImage + case couldNotConvertToJpeg + case invalidFileFormat +} + +// Represents a possible attachment to upload. +// The attachment may be invalid. +// +// Signal attachments are subject to validation and +// in some cases, file format conversion. +// +// This class gathers that logic. It offers factory methods +// for attachments that do the necessary work. +// +// The return value for the factory methods will be nil if the input is nil. +// +// [SignalAttachment hasError] will be true for non-valid attachments. +// +// TODO: Perhaps do conversion off the main thread? +// TODO: Show error on error. +// TODO: Show progress on upload. +class SignalAttachment: NSObject { + + static let TAG = "[SignalAttachment]" + + // MARK: Properties + + let data : Data! + + // Attachment types are identified using UTIs. + // + // See: https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html + let dataUTI : String! + + var error : SignalAttachmentError? { + didSet { + AssertIsOnMainThread() + + assert(oldValue == nil) + Logger.verbose("\(SignalAttachment.TAG) Attachment has error: \(error)") + } + } + + // MARK: Constants + + /** + * Media Size constraints from Signal-Android + * (org/thoughtcrime/securesms/mms/PushMediaConstraints.java) + */ + static let kMaxFileSize_Gif = 5 * 1024 * 1024 + static let kMaxFileSize_Image = 420 * 1024 + static let kMaxFileSize_Video = 100 * 1024 * 1024 + static let kMaxFileSize_Audio = 100 * 1024 * 1024 + // TODO: What should the max file size on "other" attachments be? + static let kMaxFileSize_Generic = 25 * 1024 * 1024 + + // MARK: Constructor + + // This method should not be called directly; use the factory + // methods instead. + internal required init(data : Data!, dataUTI : String!) { + self.data = data + self.dataUTI = dataUTI + super.init() + } + + public func hasError() -> Bool { + return error != nil + } + + // Returns the MIME type for this attachment or nil if no MIME type + // can be identified. + public func mimeType() -> String? { + let mimeType = UTTypeCopyPreferredTagWithClass(dataUTI as CFString, kUTTagClassMIMEType) + guard mimeType != nil else { + return nil + } + return mimeType?.takeRetainedValue() as? String + } + + // Returns the set of UTIs that correspond to valid _input_ image formats + // for Signal attachments. + // + // Image attachments may be converted to another image format before + // being uploaded. + // + // TODO: We need to finalize which formats we support. + private class func inputImageUTISet() -> Set! { + return [ + kUTTypeJPEG as String, + kUTTypeGIF as String, + kUTTypePNG as String, + ] + } + + // Returns the set of UTIs that correspond to valid _output_ image formats + // for Signal attachments. + // + // TODO: We need to finalize which formats we support. + private class func outputImageUTISet() -> Set! { + return [ + kUTTypeJPEG as String, + kUTTypeGIF as String, + kUTTypePNG as String, + ] + } + + // Returns the set of UTIs that correspond to valid video formats + // for Signal attachments. + // + // TODO: We need to finalize which formats we support. + private class func videoUTISet() -> Set! { + return [ + kUTTypeMPEG4 as String, + ] + } + + // Returns the set of UTIs that correspond to valid audio formats + // for Signal attachments. + // + // TODO: We need to finalize which formats we support. + private class func audioUTISet() -> Set! { + return [ + kUTTypeMP3 as String, + kUTTypeMPEG4Audio as String, + ] + } + + // Returns the set of UTIs that correspond to valid input formats + // for Signal attachments. + public class func validInputUTISet() -> Set! { + return inputImageUTISet().union(videoUTISet().union(audioUTISet())) + } + + // Returns an attachment from the pasteboard, or nil if no attachment + // can be found. + // + // NOTE: The attachment returned by this method may not be valid. + // Check the attachment's error property. + public class func attachmentFromPasteboard() -> SignalAttachment? { + guard UIPasteboard.general.numberOfItems == 1 else { + // Ignore pasteboard if it contains multiple items. + // + // TODO: Should we try to use the first? + return nil + } + let pasteboardUTISet = Set(UIPasteboard.general.types) + for dataUTI in inputImageUTISet() { + if pasteboardUTISet.contains(dataUTI) { + let imageData = UIPasteboard.general.data(forPasteboardType:dataUTI) + return imageAttachment(withData : imageData, dataUTI : dataUTI) + } + } + for dataUTI in videoUTISet() { + if pasteboardUTISet.contains(dataUTI) { + let imageData = UIPasteboard.general.data(forPasteboardType:dataUTI) + return videoAttachment(withData : imageData, dataUTI : dataUTI) + } + } + for dataUTI in audioUTISet() { + if pasteboardUTISet.contains(dataUTI) { + let imageData = UIPasteboard.general.data(forPasteboardType:dataUTI) + return audioAttachment(withData : imageData, dataUTI : dataUTI) + } + } + // TODO: We could handle generic attachments at this point. + + return nil + } + + // MARK: Image Attachments + + // Factory method for an image attachment. + // + // NOTE: The attachment returned by this method may not be valid. + // Check the attachment's error property. + public class func imageAttachment(withData imageData : Data?, dataUTI : String!) -> SignalAttachment! { + assert(dataUTI.characters.count > 0) + + assert(imageData != nil) + guard let imageData = imageData else { + return nil + } + + let attachment = SignalAttachment(data : imageData, dataUTI: dataUTI) + + guard inputImageUTISet().contains(dataUTI) else { + attachment.error = .invalidFileFormat + return attachment + } + + guard imageData.count > 0 else { + assert(imageData.count > 0) + attachment.error = .invalidData + return attachment + } + + if dataUTI == kUTTypeGIF as String { + guard imageData.count <= kMaxFileSize_Gif else { + attachment.error = .fileSizeTooLarge + return attachment + } + // We don't re-encode GIFs as JPEGs, presumably in case they are + // animated. + // + // TODO: Consider re-encoding non-animated GIFs as JPEG? + Logger.verbose("\(TAG) Sending raw image/gif to retain any animation") + return attachment + } else { + guard let image = UIImage(data:imageData) else { + attachment.error = .couldNotParseImage + return attachment + } + + // If the proposed attachment already is a JPEG, + // and already conforms to the file size and + // content size limits, don't recompress it. + // + // TODO: Should non-JPEGs always be converted to JPEG? + if dataUTI == kUTTypeJPEG as String { + let imageUploadQuality = Environment.preferences().imageUploadQuality() + let maxSize = maxSizeForImage(image: image, imageUploadQuality:imageUploadQuality) + if (image.size.width <= maxSize && + image.size.height <= maxSize && + imageData.count <= kMaxFileSize_Image) { + Logger.verbose("\(TAG) Sending raw image/jpeg") + return attachment + } + } + + Logger.verbose("\(TAG) Converting attachment to image/jpeg") + return recompressImageAsJPEG(image : image, attachment : attachment) + } + } + + // Factory method for an image attachment. + // + // NOTE: The attachment returned by this method may nil or not be valid. + // Check the attachment's error property. + public class func imageAttachment(withImage image : UIImage?, dataUTI : String!) -> SignalAttachment! { + assert(dataUTI.characters.count > 0) + + guard let image = image else { + return nil + } + + // Make a placeholder attachment on which to hang errors if necessary. + let attachment = SignalAttachment(data : Data(), dataUTI: dataUTI) + + Logger.verbose("\(TAG) Converting attachment to image/jpeg") + return recompressImageAsJPEG(image : image, attachment : attachment) + } + + private class func recompressImageAsJPEG(image : UIImage!, attachment : SignalAttachment!) -> SignalAttachment! { + assert(attachment.error == nil) + + var imageUploadQuality = Environment.preferences().imageUploadQuality() + + while true { + let maxSize = maxSizeForImage(image: image, imageUploadQuality:imageUploadQuality) + let resizedImage = imageScaled(image, toMaxSize: maxSize) + guard let jpgImageData = UIImageJPEGRepresentation(resizedImage, + jpegCompressionQuality(imageUploadQuality:imageUploadQuality)) else { + attachment.error = .couldNotConvertToJpeg + return attachment + } + + if jpgImageData.count <= kMaxFileSize_Image { + return SignalAttachment(data : jpgImageData, dataUTI: kUTTypeJPEG as String) + } + + // If the JPEG output is larger than the file size limit, + // continue to try again by progressively reducing the + // image upload quality. + switch imageUploadQuality { + case .uncropped: + imageUploadQuality = .high + case .high: + imageUploadQuality = .medium + case .medium: + imageUploadQuality = .low + case .low: + attachment.error = .fileSizeTooLarge + return attachment + } + } + } + + private class func imageScaled(_ image: UIImage, toMaxSize size: CGFloat) -> UIImage { + var scaleFactor: CGFloat + let aspectRatio: CGFloat = image.size.height / image.size.width + if aspectRatio > 1 { + scaleFactor = size / image.size.width + } + else { + scaleFactor = size / image.size.height + } + let newSize = CGSize(width: CGFloat(image.size.width * scaleFactor), height: CGFloat(image.size.height * scaleFactor)) + UIGraphicsBeginImageContext(newSize) + image.draw(in: CGRect(x: CGFloat(0), y: CGFloat(0), width: CGFloat(newSize.width), height: CGFloat(newSize.height))) + let updatedImage: UIImage? = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return updatedImage! + } + + private class func maxSizeForImage(image: UIImage, imageUploadQuality: TSImageQuality) -> CGFloat { + switch imageUploadQuality { + case .uncropped: + return max(image.size.width, image.size.height) + case .high: + return 2048 + case .medium: + return 1024 + case .low: + return 512 + } + } + + private class func jpegCompressionQuality(imageUploadQuality: TSImageQuality) -> CGFloat { + switch imageUploadQuality { + case .uncropped: + return 1 + case .high: + return 0.9 + case .medium: + return 0.5 + case .low: + return 0.3 + } + } + + // MARK: Video Attachments + + // Factory method for video attachments. + // + // NOTE: The attachment returned by this method may not be valid. + // Check the attachment's error property. + public class func videoAttachment(withData data : Data?, dataUTI : String!) -> SignalAttachment! { + return newAttachment(withData : data, + dataUTI : dataUTI, + validUTISet : videoUTISet(), + maxFileSize : kMaxFileSize_Video) + } + + // MARK: Audio Attachments + + // Factory method for audio attachments. + // + // NOTE: The attachment returned by this method may not be valid. + // Check the attachment's error property. + public class func audioAttachment(withData data : Data?, dataUTI : String!) -> SignalAttachment! { + return newAttachment(withData : data, + dataUTI : dataUTI, + validUTISet : audioUTISet(), + maxFileSize : kMaxFileSize_Audio) + } + + // MARK: Generic Attachments + + // Factory method for generic attachments. + // + // NOTE: The attachment returned by this method may not be valid. + // Check the attachment's error property. + public class func genericAttachment(withData data : Data?, dataUTI : String!) -> SignalAttachment! { + return newAttachment(withData : data, + dataUTI : dataUTI, + validUTISet : nil, + maxFileSize : kMaxFileSize_Generic) + } + + // MARK: Helper Methods + + private class func newAttachment(withData data : Data?, + dataUTI : String!, + validUTISet : Set?, + maxFileSize : Int) -> SignalAttachment! { + assert(dataUTI.characters.count > 0) + + assert(data != nil) + guard let data = data else { + return nil + } + + let attachment = SignalAttachment(data : data, dataUTI: dataUTI) + + if validUTISet != nil { + guard validUTISet!.contains(dataUTI) else { + attachment.error = .invalidFileFormat + return attachment + } + } + + guard data.count > 0 else { + assert(data.count > 0) + attachment.error = .invalidData + return attachment + } + + guard data.count <= maxFileSize else { + attachment.error = .fileSizeTooLarge + return attachment + } + + // Attachment is valid + return attachment + } +} From 7f2810af3fa2a91434706bf0640c1235e33ad43f Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 10 Mar 2017 10:56:12 -0300 Subject: [PATCH 02/11] Update MessagesViewController to use SignalAttachment. // FREEBIE --- .../view controllers/MessagesViewController.h | 18 + .../view controllers/MessagesViewController.m | 367 ++++++++++++------ 2 files changed, 256 insertions(+), 129 deletions(-) diff --git a/Signal/src/view controllers/MessagesViewController.h b/Signal/src/view controllers/MessagesViewController.h index de5f60a36f..6768089ef3 100644 --- a/Signal/src/view controllers/MessagesViewController.h +++ b/Signal/src/view controllers/MessagesViewController.h @@ -14,6 +14,24 @@ extern NSString *const OWSMessagesViewControllerDidAppearNotification; +@interface OWSMessagesComposerTextView : JSQMessagesComposerTextView + +@end + +#pragma mark - + +@interface OWSMessagesToolbarContentView : JSQMessagesToolbarContentView + +@end + +#pragma mark - + +@interface OWSMessagesInputToolbar : JSQMessagesInputToolbar + +@end + +#pragma mark - + @interface MessagesViewController : JSQMessagesViewController + +- (void)didPasteAttachment:(SignalAttachment * _Nullable)attachment; + +@end + +#pragma mark - + +@interface OWSMessagesComposerTextView () + +@property (weak, nonatomic) id textViewPasteDelegate; + +@end + +#pragma mark - + +@implementation OWSMessagesComposerTextView + +- (BOOL)canBecomeFirstResponder { + return YES; +} + +- (BOOL)pasteBoardHasPossibleAttachment { + NSSet *pasteboardUTISet = [NSSet setWithArray:[UIPasteboard generalPasteboard].pasteboardTypes]; + if ([UIPasteboard generalPasteboard].numberOfItems == 1 && + [[SignalAttachment validInputUTISet] intersectsSet:pasteboardUTISet]) { + // We don't want to load/convert images more than once so we + // only do a cursory validation pass at this time. + return YES; + } + return NO; +} + +- (BOOL)canPerformAction:(SEL)action withSender:(id)sender { + if (action == @selector(paste:)) { + DDLogWarn(@"UIPasteboard.generalPasteboard(): %@.", + [UIPasteboard generalPasteboard].pasteboardTypes); + DDLogWarn(@"UIPasteboard.generalPasteboard(): %d.", + (int) [UIPasteboard generalPasteboard].numberOfItems); + if ([self pasteBoardHasPossibleAttachment]) { + return YES; + } + } + return [super canPerformAction:action withSender:sender]; +} + +- (void)paste:(id)sender { + if ([self pasteBoardHasPossibleAttachment]) { + SignalAttachment *attachment = [SignalAttachment attachmentFromPasteboard]; + // Note: attachment might be nil or have an error at this point; that's fine. + [self.textViewPasteDelegate didPasteAttachment:attachment]; + return; + } + + [super paste:sender]; +} + +@end + +#pragma mark - + +@implementation OWSMessagesToolbarContentView + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([OWSMessagesToolbarContentView class]) + bundle:[NSBundle bundleForClass:[OWSMessagesToolbarContentView class]]]; +} + +@end + +#pragma mark - + +@implementation OWSMessagesInputToolbar + +- (JSQMessagesToolbarContentView *)loadToolbarContentView { + NSArray *views = [[OWSMessagesToolbarContentView nib] instantiateWithOwner:nil + options:nil]; + OWSAssert(views.count == 1); + OWSMessagesToolbarContentView *view = views[0]; + OWSAssert([view isKindOfClass:[OWSMessagesToolbarContentView class]]); + return view; +} + +@end + +#pragma mark - + +@interface MessagesViewController () { UIImage *tappedImage; BOOL isGroupConversation; - + UIView *_unreadContainer; UIImageView *_unreadBackground; UILabel *_unreadLabel; @@ -160,7 +249,18 @@ typedef enum : NSUInteger { } [self commonInit]; + + return self; +} +- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil { + self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; + if (!self) { + return self; + } + + [self commonInit]; + return self; } @@ -665,6 +765,11 @@ typedef enum : NSUInteger { // prevent draft from obscuring message history in case user wants to scroll back to refer to something // while composing a long message. self.inputToolbar.maximumHeight = 300; + + OWSAssert(self.inputToolbar.contentView); + OWSAssert(self.inputToolbar.contentView.textView); + self.inputToolbar.contentView.textView.pasteDelegate = self; + ((OWSMessagesComposerTextView *) self.inputToolbar.contentView.textView).textViewPasteDelegate = self; } - (nullable UILabel *)findNavbarTitleLabel @@ -1211,7 +1316,11 @@ typedef enum : NSUInteger { DDLogDebug(@"%@ Ignoring request to show conversation settings, since user left group", self.tag); return; } - [self performSegueWithIdentifier:OWSMessagesViewControllerSeguePushConversationSettings sender:self]; + + OWSConversationSettingsTableViewController *settingsVC = [[UIStoryboard storyboardWithName:AppDelegateStoryboardMain bundle:NULL] + instantiateViewControllerWithIdentifier:@"OWSConversationSettingsTableViewController"]; + [settingsVC configureWithThread:self.thread]; + [self.navigationController pushViewController:settingsVC animated:YES]; } - (void)didTapTitle @@ -1809,7 +1918,14 @@ typedef enum : NSUInteger { UIImage *imageFromCamera = [info[UIImagePickerControllerOriginalImage] normalizedImage]; if (imageFromCamera) { - [self sendMessageAttachment:[self qualityAdjustedAttachmentForImage:imageFromCamera] ofType:@"image/jpeg"]; + SignalAttachment *attachment = [SignalAttachment imageAttachmentWithImage:imageFromCamera + dataUTI:(NSString *) kUTTypeJPEG]; + if (!attachment || + [attachment hasError]) { + failedToPickAttachment(nil); + } else { + [self sendMessageAttachment:attachment]; + } } else { failedToPickAttachment(nil); } @@ -1827,46 +1943,44 @@ typedef enum : NSUInteger { options.networkAccessAllowed = YES; // iCloud OK options.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; // Don't need quick/dirty version [[PHImageManager defaultManager] - requestImageDataForAsset:asset - options:options - resultHandler:^(NSData *_Nullable imageData, - NSString *_Nullable dataUTI, - UIImageOrientation orientation, - NSDictionary *_Nullable assetInfo) { - - NSError *assetFetchingError = assetInfo[PHImageErrorKey]; - if (assetFetchingError || !imageData) { - return failedToPickAttachment(assetFetchingError); - } - DDLogVerbose( - @"Size in bytes: %lu; detected filetype: %@", (unsigned long)imageData.length, dataUTI); - - if ([dataUTI isEqualToString:(__bridge NSString *)kUTTypeGIF] - && imageData.length <= 5 * 1024 * 1024) { - DDLogVerbose(@"Sending raw image/gif to retain any animation"); - /** - * Media Size constraints lifted from Signal-Android - * (org/thoughtcrime/securesms/mms/PushMediaConstraints.java) - * - * GifMaxSize return 5 * MB; - * For reference, other media size limits we're not explicitly enforcing: - * ImageMaxSize return 420 * KB; - * VideoMaxSize return 100 * MB; - * getAudioMaxSize 100 * MB; - */ - [self sendMessageAttachment:imageData ofType:@"image/gif"]; - } else { - DDLogVerbose(@"Compressing attachment as image/jpeg"); - UIImage *pickedImage = [[UIImage alloc] initWithData:imageData]; - [self sendMessageAttachment:[self qualityAdjustedAttachmentForImage:pickedImage] - ofType:@"image/jpeg"]; - } - }]; + requestImageDataForAsset:asset + options:options + resultHandler:^(NSData *_Nullable imageData, + NSString *_Nullable dataUTI, + UIImageOrientation orientation, + NSDictionary *_Nullable assetInfo) { + + NSError *assetFetchingError = assetInfo[PHImageErrorKey]; + if (assetFetchingError || !imageData) { + return failedToPickAttachment(assetFetchingError); + } + OWSAssert([NSThread isMainThread]); + + SignalAttachment *attachment = [SignalAttachment imageAttachmentWithData:imageData + dataUTI:dataUTI]; + if (!attachment || + [attachment hasError]) { + // TODO: Should we create an NSError? + failedToPickAttachment(nil); + } else { + [self dismissViewControllerAnimated:YES + completion:^{ + OWSAssert([NSThread isMainThread]); + [self sendMessageAttachment:attachment]; + }]; + } + }]; } } -- (void)sendMessageAttachment:(NSData *)attachmentData ofType:(NSString *)attachmentType +- (void)sendMessageAttachment:(SignalAttachment *)attachment { + OWSAssert([NSThread isMainThread]); + // TODO: Should we assume non-nil or should we check for non-nil? + OWSAssert(attachment != nil); + OWSAssert(![attachment hasError]); + OWSAssert([attachment mimeType].length > 0); + TSOutgoingMessage *message; OWSDisappearingMessagesConfiguration *configuration = [OWSDisappearingMessagesConfiguration fetchObjectWithUniqueID:self.thread.uniqueId]; @@ -1882,25 +1996,20 @@ typedef enum : NSUInteger { messageBody:nil attachmentIds:[NSMutableArray new]]; } - - dispatch_async(dispatch_get_main_queue(), ^{ - [self dismissViewControllerAnimated:YES - completion:^{ - DDLogVerbose(@"Sending attachment. Size in bytes: %lu, contentType: %@", - (unsigned long)attachmentData.length, - attachmentType); - [self.messageSender sendAttachmentData:attachmentData - contentType:attachmentType - inMessage:message - success:^{ - DDLogDebug(@"%@ Successfully sent message attachment.", self.tag); - } - failure:^(NSError *error) { - DDLogError( - @"%@ Failed to send message attachment with error: %@", self.tag, error); - }]; - }]; - }); + + DDLogVerbose(@"Sending attachment. Size in bytes: %lu, contentType: %@", + (unsigned long)attachment.data.length, + [attachment mimeType]); + [self.messageSender sendAttachmentData:attachment.data + contentType:[attachment mimeType] + inMessage:message + success:^{ + DDLogDebug(@"%@ Successfully sent message attachment.", self.tag); + } + failure:^(NSError *error) { + DDLogError( + @"%@ Failed to send message attachment with error: %@", self.tag, error); + }]; } - (NSURL *)videoTempFolder { @@ -1930,75 +2039,24 @@ typedef enum : NSUInteger { exportSession.outputURL = compressedVideoUrl; [exportSession exportAsynchronouslyWithCompletionHandler:^{ - NSError *error; - [self sendMessageAttachment:[NSData dataWithContentsOfURL:compressedVideoUrl] ofType:@"video/mp4"]; - [[NSFileManager defaultManager] removeItemAtURL:compressedVideoUrl error:&error]; - if (error) { - DDLogWarn(@"Failed to remove cached video file: %@", error.debugDescription); - } + NSData *videoData = [NSData dataWithContentsOfURL:compressedVideoUrl]; + SignalAttachment *attachment = [SignalAttachment videoAttachmentWithData:videoData + dataUTI:(NSString *) kUTTypeMPEG4]; + if (!attachment || + [attachment hasError]) { + // TODO: How should we handle errors here? + } else { + [self sendMessageAttachment:attachment]; + } + + NSError *error; + [[NSFileManager defaultManager] removeItemAtURL:compressedVideoUrl error:&error]; + if (error) { + DDLogWarn(@"Failed to remove cached video file: %@", error.debugDescription); + } }]; } -- (NSData *)qualityAdjustedAttachmentForImage:(UIImage *)image { - return UIImageJPEGRepresentation([self adjustedImageSizedForSending:image], [self compressionRate]); -} - -- (UIImage *)adjustedImageSizedForSending:(UIImage *)image { - CGFloat correctedWidth; - switch ([Environment.preferences imageUploadQuality]) { - case TSImageQualityUncropped: - return image; - - case TSImageQualityHigh: - correctedWidth = 2048; - break; - case TSImageQualityMedium: - correctedWidth = 1024; - break; - case TSImageQualityLow: - correctedWidth = 512; - break; - default: - break; - } - - return [self imageScaled:image toMaxSize:correctedWidth]; -} - -- (UIImage *)imageScaled:(UIImage *)image toMaxSize:(CGFloat)size { - CGFloat scaleFactor; - CGFloat aspectRatio = image.size.height / image.size.width; - - if (aspectRatio > 1) { - scaleFactor = size / image.size.width; - } else { - scaleFactor = size / image.size.height; - } - - CGSize newSize = CGSizeMake(image.size.width * scaleFactor, image.size.height * scaleFactor); - - UIGraphicsBeginImageContext(newSize); - [image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)]; - UIImage *updatedImage = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - - return updatedImage; -} - -- (CGFloat)compressionRate { - switch ([Environment.preferences imageUploadQuality]) { - case TSImageQualityUncropped: - return 1; - case TSImageQualityHigh: - return 0.9f; - case TSImageQualityMedium: - return 0.5f; - case TSImageQualityLow: - return 0.3f; - default: - break; - } -} #pragma mark Storage access @@ -2192,7 +2250,15 @@ typedef enum : NSUInteger { - (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag { if (flag) { - [self sendMessageAttachment:[NSData dataWithContentsOfURL:recorder.url] ofType:@"audio/m4a"]; + NSData *audioData = [NSData dataWithContentsOfURL:recorder.url]; + SignalAttachment *attachment = [SignalAttachment audioAttachmentWithData:audioData + dataUTI:(NSString *) kUTTypeMPEG4Audio]; + if (!attachment || + [attachment hasError]) { + // TODO: How should we handle errors here? + } else { + [self sendMessageAttachment:attachment]; + } } } @@ -2408,7 +2474,50 @@ typedef enum : NSUInteger { [self showConversationSettings]; } } - + +#pragma mark - JSQMessagesComposerTextViewPasteDelegate + +- (BOOL)composerTextView:(JSQMessagesComposerTextView *)textView + shouldPasteWithSender:(id)sender { + DDLogError(@"%@ sender: %@", + self.tag, + sender); + return YES; +} + +#pragma mark - OWSTextViewPasteDelegate + +- (void)didPasteAttachment:(SignalAttachment * _Nullable)attachment { + DDLogError(@"%@ %s", + self.tag, + __PRETTY_FUNCTION__); + + if (attachment == nil || + [attachment hasError]) { + // TODO: Add UI. + } else { + + // TODO: Add UI. + UIViewController *viewController = [[AttachmentApprovalViewController alloc] initWithAttachment:attachment]; + // [self tryToSendMessageAttachmentWithData:imageData + // dataUTI:dataUTI]; + } +} + +#pragma mark - Class methods + ++ (UINib *)nib +{ + return [UINib nibWithNibName:NSStringFromClass([MessagesViewController class]) + bundle:[NSBundle bundleForClass:[MessagesViewController class]]]; +} + ++ (instancetype)messagesViewController +{ + return [[[self class] alloc] initWithNibName:NSStringFromClass([MessagesViewController class]) + bundle:[NSBundle bundleForClass:[MessagesViewController class]]]; +} + #pragma mark - Logging + (NSString *)tag From cd928cd9bec211f47e76ad7ddacb668056dbed1a Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 10 Mar 2017 10:59:59 -0300 Subject: [PATCH 03/11] Update MessagesViewController to use SignalAttachment. // FREEBIE --- Signal.xcodeproj/project.pbxproj | 16 + Signal/src/Storyboard/Main.storyboard | 27 +- .../AttachmentApprovalViewController.swift | 991 ++++++++++++++++++ .../MessageComposeTableViewController.h | 4 - .../MessagesViewController.xib | 58 + ...SConversationSettingsTableViewController.m | 27 +- .../OWSMessagesToolbarContentView.xib | 71 ++ .../view controllers/SignalsViewController.m | 4 +- 8 files changed, 1181 insertions(+), 17 deletions(-) create mode 100644 Signal/src/view controllers/AttachmentApprovalViewController.swift create mode 100644 Signal/src/view controllers/MessagesViewController.xib create mode 100644 Signal/src/view controllers/OWSMessagesToolbarContentView.xib diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 6f85706c06..f29eac74d5 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -11,6 +11,10 @@ 341BB7491DB727EE001E2975 /* JSQMediaItem+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = 341BB7481DB727EE001E2975 /* JSQMediaItem+OWS.m */; }; 344F2F671E57A932000D9322 /* UIViewController+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = 344F2F661E57A932000D9322 /* UIViewController+OWS.m */; }; 34535D821E256BE9008A4747 /* UIView+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = 34535D811E256BE9008A4747 /* UIView+OWS.m */; }; + 348A08421E6A044E0057E290 /* MessagesViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 348A08411E6A044E0057E290 /* MessagesViewController.xib */; }; + 348A08441E6A1D2C0057E290 /* OWSMessagesToolbarContentView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 348A08431E6A1D2C0057E290 /* OWSMessagesToolbarContentView.xib */; }; + 348A08511E6C73490057E290 /* AttachmentApprovalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348A08501E6C73490057E290 /* AttachmentApprovalViewController.swift */; }; + 348A08531E6C75590057E290 /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348A08521E6C75590057E290 /* SignalAttachment.swift */; }; 348F3A4F1E4A533900750D44 /* CallInterstitialViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348F3A4E1E4A533900750D44 /* CallInterstitialViewController.swift */; }; 34FD93701E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 34FD936F1E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m */; }; 4505C2BF1E648EA300CEBF41 /* ExperienceUpgrade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4505C2BE1E648EA300CEBF41 /* ExperienceUpgrade.swift */; }; @@ -624,6 +628,10 @@ 344F2F661E57A932000D9322 /* UIViewController+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "UIViewController+OWS.m"; path = "util/UIViewController+OWS.m"; sourceTree = ""; }; 34535D801E256BE9008A4747 /* UIView+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIView+OWS.h"; sourceTree = ""; }; 34535D811E256BE9008A4747 /* UIView+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIView+OWS.m"; sourceTree = ""; }; + 348A08411E6A044E0057E290 /* MessagesViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MessagesViewController.xib; sourceTree = ""; }; + 348A08431E6A1D2C0057E290 /* OWSMessagesToolbarContentView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OWSMessagesToolbarContentView.xib; sourceTree = ""; }; + 348A08501E6C73490057E290 /* AttachmentApprovalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentApprovalViewController.swift; sourceTree = ""; }; + 348A08521E6C75590057E290 /* SignalAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignalAttachment.swift; sourceTree = ""; }; 348F3A4E1E4A533900750D44 /* CallInterstitialViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallInterstitialViewController.swift; sourceTree = ""; }; 34FD936E1E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAnyTouchGestureRecognizer.h; path = views/OWSAnyTouchGestureRecognizer.h; sourceTree = ""; }; 34FD936F1E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSAnyTouchGestureRecognizer.m; path = views/OWSAnyTouchGestureRecognizer.m; sourceTree = ""; }; @@ -2653,6 +2661,7 @@ FC3196321A08142D0094C78E /* Signals */ = { isa = PBXGroup; children = ( + 348A08501E6C73490057E290 /* AttachmentApprovalViewController.swift */, 348F3A4E1E4A533900750D44 /* CallInterstitialViewController.swift */, 4509E79B1DD6545B0025A59F /* CallViewController.swift */, FC31962B1A06A2190094C78E /* FingerprintViewController.h */, @@ -2663,12 +2672,15 @@ FC3196291A067D8F0094C78E /* MessageComposeTableViewController.m */, FCAC964F19FF0A6E0046DFC5 /* MessagesViewController.h */, FCAC965019FF0A6E0046DFC5 /* MessagesViewController.m */, + 348A08411E6A044E0057E290 /* MessagesViewController.xib */, FCFD256D1A151BCB00F4C644 /* NewGroupViewController.h */, FCFD256E1A151BCB00F4C644 /* NewGroupViewController.m */, 452E3C8C1D935C77002A45B0 /* OWSConversationSettingsTableViewController.h */, 452E3C8D1D935C77002A45B0 /* OWSConversationSettingsTableViewController.m */, + 348A08431E6A1D2C0057E290 /* OWSMessagesToolbarContentView.xib */, A5D0699A1A50E9CB004CB540 /* ShowGroupMembersViewController.h */, A5D069991A50E9CB004CB540 /* ShowGroupMembersViewController.m */, + 348A08521E6C75590057E290 /* SignalAttachment.swift */, FC4FA0241A1B9DC600DA100A /* SignalsNavigationController.h */, FC4FA0251A1B9DC600DA100A /* SignalsNavigationController.m */, FCAC963A19FEF9280046DFC5 /* SignalsViewController.h */, @@ -2897,6 +2909,7 @@ E94066151DFC5B7B00B15392 /* ContactsPicker.xib in Resources */, AD41D7B61A6F6F0600241130 /* play_button@2x.png in Resources */, AD83FF3F1A73426500B5C81A /* audio_pause_button_blue.png in Resources */, + 348A08421E6A044E0057E290 /* MessagesViewController.xib in Resources */, 45E1F3A31DEF1DF000852CF1 /* NoSignalContactsView.xib in Resources */, A5509ECA1A69AB8B00ABA4BC /* Main.storyboard in Resources */, A507A3B11A6C60E300BEED0D /* InboxTableViewCell.xib in Resources */, @@ -2941,6 +2954,7 @@ E1370BE618A0686C00826894 /* sonarping.mp3 in Resources */, B10C9B5F1A7049EC00ECA2BF /* pause_icon.png in Resources */, AD83FF471A73428300B5C81A /* audio_play_button_blue.png in Resources */, + 348A08441E6A1D2C0057E290 /* OWSMessagesToolbarContentView.xib in Resources */, AD83FF451A73426500B5C81A /* audio_pause_button@2x.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3137,6 +3151,7 @@ 45387B041E36D650005D00B3 /* OWS102MoveLoggingPreferenceToUserDefaults.m in Sources */, E197B61818BBEC1A00F073E5 /* RemoteIOAudio.m in Sources */, B67ADDC41989FF8700E1A773 /* RPServerRequestsManager.m in Sources */, + 348A08511E6C73490057E290 /* AttachmentApprovalViewController.swift in Sources */, 348F3A4F1E4A533900750D44 /* CallInterstitialViewController.swift in Sources */, EF764C351DB67CC5000D9A87 /* UIViewController+CameraPermissions.m in Sources */, 453201251E71100C00F20761 /* DisplayableTextFilter.swift in Sources */, @@ -3147,6 +3162,7 @@ 76EB05E018170B33006006FC /* NetworkStream.m in Sources */, 45794E861E00620000066731 /* CallUIAdapter.swift in Sources */, FCFA64B71A24F6730007FB87 /* UIFont+OWS.m in Sources */, + 348A08531E6C75590057E290 /* SignalAttachment.swift in Sources */, B6B9ECFC198B31BA00C620D3 /* PushManager.m in Sources */, 45DF5DF21DDB843F00C936C7 /* CompareSafetyNumbersActivity.swift in Sources */, 76EB05D618170B33006006FC /* ZrtpResponder.m in Sources */, diff --git a/Signal/src/Storyboard/Main.storyboard b/Signal/src/Storyboard/Main.storyboard index 1ef96d8602..47cff25a39 100644 --- a/Signal/src/Storyboard/Main.storyboard +++ b/Signal/src/Storyboard/Main.storyboard @@ -120,12 +120,33 @@ + + + + + + + + + + + + + + + + + + + + + @@ -352,7 +373,7 @@ - + @@ -625,7 +646,7 @@ - + @@ -1701,7 +1722,7 @@ + - diff --git a/Signal/src/view controllers/AttachmentApprovalViewController.swift b/Signal/src/view controllers/AttachmentApprovalViewController.swift new file mode 100644 index 0000000000..8c49ba8b46 --- /dev/null +++ b/Signal/src/view controllers/AttachmentApprovalViewController.swift @@ -0,0 +1,991 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import Foundation +//import WebRTC +//import PromiseKit + +//// TODO: Add category so that button handlers can be defined where button is created. +//// TODO: Ensure buttons enabled & disabled as necessary. +//@objc(OWSAttachmentApprovalViewController) +class AttachmentApprovalViewController: UIViewController { + + let TAG = "[AttachmentApprovalViewController]" + + // Dependencies + +// let callUIAdapter: CallUIAdapter +// let contactsManager: OWSContactsManager + + // MARK: Properties + + let attachment : SignalAttachment! + +// var thread: TSContactThread! +// var call: SignalCall! +// var hasDismissed = false + + // MARK: Views + +// var hasConstraints = false +// var blurView: UIVisualEffectView! +// var dateFormatter: DateFormatter? +// +// // MARK: Contact Views +// +// var contactNameLabel: UILabel! +// var contactAvatarView: AvatarImageView! +// var callStatusLabel: UILabel! +// var callDurationTimer: Timer? +// +// // MARK: Ongoing Call Controls +// +// var ongoingCallView: UIView! +// +// var hangUpButton: UIButton! +// var speakerPhoneButton: UIButton! +// var audioModeMuteButton: UIButton! +// var audioModeVideoButton: UIButton! +// var videoModeMuteButton: UIButton! +// var videoModeVideoButton: UIButton! +// // TODO: Later, we'll re-enable the text message button +// // so users can send and read messages during a +// // call. +//// var textMessageButton: UIButton! +// +// // MARK: Incoming Call Controls +// +// var incomingCallView: UIView! +// +// var acceptIncomingButton: UIButton! +// var declineIncomingButton: UIButton! +// +// // MARK: Video Views +// +// var remoteVideoView: RTCEAGLVideoView! +// var localVideoView: RTCCameraPreviewView! +// weak var localVideoTrack: RTCVideoTrack? +// weak var remoteVideoTrack: RTCVideoTrack? +// var remoteVideoSize: CGSize! = CGSize.zero +// var remoteVideoConstraints: [NSLayoutConstraint] = [] +// var localVideoConstraints: [NSLayoutConstraint] = [] +// +// var shouldRemoteVideoControlsBeHidden = false { +// didSet { +// updateCallUI(callState: call.state) +// } +// } +// +// // MARK: Settings Nag Views +// +// var isShowingSettingsNag = false { +// didSet { +// if oldValue != isShowingSettingsNag { +// updateCallUI(callState: call.state) +// } +// } +// } +// var settingsNagView: UIView! +// var settingsNagDescriptionLabel: UILabel! + + // MARK: Initializers + + required init?(coder aDecoder: NSCoder) { + self.attachment = SignalAttachment.genericAttachment(withData: nil, + dataUTI: kUTTypeContent as String) +// contactsManager = Environment.getCurrent().contactsManager +// callUIAdapter = Environment.getCurrent().callUIAdapter + super.init(coder: aDecoder) + // TODO: How to deprecate constructor in Swift? + assert(false) +// observeNotifications() + } + + required init(attachment : SignalAttachment!) { +// let attachmentData : NSData +// let attachmentType : String +// +// contactsManager = Environment.getCurrent().contactsManager +// callUIAdapter = Environment.getCurrent().callUIAdapter + self.attachment = attachment + super.init(nibName: nil, bundle: nil) +// observeNotifications() + } + +// func observeNotifications() { +// NotificationCenter.default.addObserver(self, +// selector:#selector(didBecomeActive), +// name:NSNotification.Name.UIApplicationDidBecomeActive, +// object:nil) +// } +// +// deinit { +// NotificationCenter.default.removeObserver(self) +// } +// +// func didBecomeActive() { +// shouldRemoteVideoControlsBeHidden = false +// } + + // MARK: View Lifecycle + +// override func viewDidDisappear(_ animated: Bool) { +// super.viewDidDisappear(animated) +// +// callDurationTimer?.invalidate() +// callDurationTimer = nil +// } +// +// override func viewWillAppear(_ animated: Bool) { +// super.viewWillAppear(animated) +// +// updateCallUI(callState: call.state) +// } +// + +// override func loadView() { +// self.view = +// } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = UIColor.black +// +// guard let thread = self.thread else { +// Logger.error("\(TAG) tried to show call call without specifying thread.") +// showCallFailed(error: OWSErrorMakeAssertionError()) +// return +// } + + createViews() + +// contactNameLabel.text = contactsManager.displayName(forPhoneIdentifier: thread.contactIdentifier()) +// contactAvatarView.image = OWSAvatarBuilder.buildImage(for: thread, contactsManager: contactsManager) +// +// assert(call != nil) +// // Subscribe for future call updates +// call.addObserverAndSyncState(observer: self) +// +// Environment.getCurrent().callService.addObserverAndSyncState(observer:self) + } + + // MARK: - Create Views + + func createViews() { +// self.view.isUserInteractionEnabled = true +// self.view.addGestureRecognizer(OWSAnyTouchGestureRecognizer(target:self, +// action:#selector(didTouchRootView))) +// +// // Dark blurred background. +// let blurEffect = UIBlurEffect(style: .dark) +// blurView = UIVisualEffectView(effect: blurEffect) +// blurView.isUserInteractionEnabled = false +// self.view.addSubview(blurView) +// +// // Create the video views first, as they are under the other views. +// createVideoViews() +// +// createContactViews() +// createOngoingCallControls() +// createIncomingCallControls() +// createSettingsNagViews() + } + +// func didTouchRootView(sender: UIGestureRecognizer) { +// if !remoteVideoView.isHidden { +// shouldRemoteVideoControlsBeHidden = !shouldRemoteVideoControlsBeHidden +// } +// } +// +// func createVideoViews() { +// remoteVideoView = RTCEAGLVideoView() +// remoteVideoView.delegate = self +// remoteVideoView.isUserInteractionEnabled = false +// localVideoView = RTCCameraPreviewView() +// remoteVideoView.isHidden = true +// localVideoView.isHidden = true +// self.view.addSubview(remoteVideoView) +// self.view.addSubview(localVideoView) +// } +// +// func createContactViews() { +// contactNameLabel = UILabel() +// contactNameLabel.font = UIFont.ows_lightFont(withSize:ScaleFromIPhone5To7Plus(32, 40)) +// contactNameLabel.textColor = UIColor.white +// contactNameLabel.layer.shadowOffset = CGSize.zero +// contactNameLabel.layer.shadowOpacity = 0.35 +// contactNameLabel.layer.shadowRadius = 4 +// self.view.addSubview(contactNameLabel) +// +// callStatusLabel = UILabel() +// callStatusLabel.font = UIFont.ows_regularFont(withSize:ScaleFromIPhone5To7Plus(19, 25)) +// callStatusLabel.textColor = UIColor.white +// callStatusLabel.layer.shadowOffset = CGSize.zero +// callStatusLabel.layer.shadowOpacity = 0.35 +// callStatusLabel.layer.shadowRadius = 4 +// self.view.addSubview(callStatusLabel) +// +// contactAvatarView = AvatarImageView() +// self.view.addSubview(contactAvatarView) +// } +// +// func createSettingsNagViews() { +// settingsNagView = UIView() +// settingsNagView.isHidden = true +// self.view.addSubview(settingsNagView) +// +// let viewStack = UIView() +// settingsNagView.addSubview(viewStack) +// viewStack.autoPinWidthToSuperview() +// viewStack.autoVCenterInSuperview() +// +// settingsNagDescriptionLabel = UILabel() +// settingsNagDescriptionLabel.text = NSLocalizedString("CALL_VIEW_SETTINGS_NAG_DESCRIPTION_ALL", +// comment: "Reminder to the user of the benefits of enabling CallKit and disabling CallKit privacy.") +// settingsNagDescriptionLabel.font = UIFont.ows_regularFont(withSize:ScaleFromIPhone5To7Plus(16, 18)) +// settingsNagDescriptionLabel.textColor = UIColor.white +// settingsNagDescriptionLabel.numberOfLines = 0 +// settingsNagDescriptionLabel.lineBreakMode = .byWordWrapping +// viewStack.addSubview(settingsNagDescriptionLabel) +// settingsNagDescriptionLabel.autoPinWidthToSuperview() +// settingsNagDescriptionLabel.autoPinEdge(toSuperviewEdge:.top) +// +// let buttonHeight = ScaleFromIPhone5To7Plus(35, 45) +// let buttonFont = UIFont.ows_regularFont(withSize:ScaleFromIPhone5To7Plus(14, 18)) +// let buttonCornerRadius = CGFloat(4) +// let descriptionVSpacingHeight = ScaleFromIPhone5To7Plus(30, 60) +// +// let callSettingsButton = UIButton() +// callSettingsButton.setTitle(NSLocalizedString("CALL_VIEW_SETTINGS_NAG_SHOW_CALL_SETTINGS", +// comment: "Label for button that shows the privacy settings"), for:.normal) +// callSettingsButton.setTitleColor(UIColor.white, for:.normal) +// callSettingsButton.titleLabel!.font = buttonFont +// callSettingsButton.addTarget(self, action:#selector(didPressShowCallSettings), for:.touchUpInside) +// callSettingsButton.backgroundColor = UIColor.ows_signalBrandBlue() +// callSettingsButton.layer.cornerRadius = buttonCornerRadius +// callSettingsButton.clipsToBounds = true +// viewStack.addSubview(callSettingsButton) +// callSettingsButton.autoSetDimension(.height, toSize:buttonHeight) +// callSettingsButton.autoPinWidthToSuperview() +// callSettingsButton.autoPinEdge(.top, to:.bottom, of:settingsNagDescriptionLabel, withOffset:descriptionVSpacingHeight) +// +// let notNowButton = UIButton() +// notNowButton.setTitle(NSLocalizedString("CALL_VIEW_SETTINGS_NAG_NOT_NOW_BUTTON", +// comment: "Label for button that dismiss the call view's settings nag."), for:.normal) +// notNowButton.setTitleColor(UIColor.white, for:.normal) +// notNowButton.titleLabel!.font = buttonFont +// notNowButton.addTarget(self, action:#selector(didPressDismissNag), for:.touchUpInside) +// notNowButton.backgroundColor = UIColor.ows_signalBrandBlue() +// notNowButton.layer.cornerRadius = buttonCornerRadius +// notNowButton.clipsToBounds = true +// viewStack.addSubview(notNowButton) +// notNowButton.autoSetDimension(.height, toSize:buttonHeight) +// notNowButton.autoPinWidthToSuperview() +// notNowButton.autoPinEdge(toSuperviewEdge:.bottom) +// notNowButton.autoPinEdge(.top, to:.bottom, of:callSettingsButton, withOffset:12) +// } +// +// func buttonSize() -> CGFloat { +// return ScaleFromIPhone5To7Plus(84, 108) +// } +// +// func buttonInset() -> CGFloat { +// return ScaleFromIPhone5To7Plus(7, 9) +// } +// +// func createOngoingCallControls() { +// +//// textMessageButton = createButton(imageName:"message-active-wide", +//// action:#selector(didPressTextMessage)) +// speakerPhoneButton = createButton(imageName:"speaker-inactive-wide", +// action:#selector(didPressSpeakerphone)) +// hangUpButton = createButton(imageName:"hangup-active-wide", +// action:#selector(didPressHangup)) +// audioModeMuteButton = createButton(imageName:"mute-unselected-wide", +// action:#selector(didPressMute)) +// videoModeMuteButton = createButton(imageName:"video-mute-unselected", +// action:#selector(didPressMute)) +// audioModeVideoButton = createButton(imageName:"video-inactive-wide", +// action:#selector(didPressVideo)) +// videoModeVideoButton = createButton(imageName:"video-video-unselected", +// action:#selector(didPressVideo)) +// +// setButtonSelectedImage(button: audioModeMuteButton, imageName: "mute-selected-wide") +// setButtonSelectedImage(button: videoModeMuteButton, imageName: "video-mute-selected") +// setButtonSelectedImage(button: audioModeVideoButton, imageName: "video-active-wide") +// setButtonSelectedImage(button: videoModeVideoButton, imageName: "video-video-selected") +// setButtonSelectedImage(button: speakerPhoneButton, imageName: "speaker-active-wide") +// +// ongoingCallView = createContainerForCallControls(controlGroups : [ +// [audioModeMuteButton, speakerPhoneButton, audioModeVideoButton ], +// [videoModeMuteButton, hangUpButton, videoModeVideoButton ] +// ]) +// } +// +// func setButtonSelectedImage(button: UIButton, imageName: String) { +// let image = UIImage(named:imageName) +// assert(image != nil) +// button.setImage(image, for:.selected) +// } +// +// func createIncomingCallControls() { +// +// acceptIncomingButton = createButton(imageName:"call-active-wide", +// action:#selector(didPressAnswerCall)) +// declineIncomingButton = createButton(imageName:"hangup-active-wide", +// action:#selector(didPressDeclineCall)) +// +// incomingCallView = createContainerForCallControls(controlGroups : [ +// [acceptIncomingButton, declineIncomingButton ] +// ]) +// } +// +// func createContainerForCallControls(controlGroups: [[UIView]]) -> UIView { +// let containerView = UIView() +// self.view.addSubview(containerView) +// var rows: [UIView] = [] +// for controlGroup in controlGroups { +// rows.append(rowWithSubviews(subviews:controlGroup)) +// } +// let rowspacing = ScaleFromIPhone5To7Plus(6, 7) +// var prevRow: UIView? +// for row in rows { +// containerView.addSubview(row) +// row.autoHCenterInSuperview() +// if prevRow != nil { +// row.autoPinEdge(.top, to:.bottom, of:prevRow!, withOffset:rowspacing) +// } +// prevRow = row +// } +// +// containerView.setContentHuggingVerticalHigh() +// rows.first!.autoPinEdge(toSuperviewEdge:.top) +// rows.last!.autoPinEdge(toSuperviewEdge:.bottom) +// return containerView +// } +// +// func createButton(imageName: String, action: Selector) -> UIButton { +// let image = UIImage(named:imageName) +// assert(image != nil) +// let button = UIButton() +// button.setImage(image, for:.normal) +// button.imageEdgeInsets = UIEdgeInsets(top: buttonInset(), +// left: buttonInset(), +// bottom: buttonInset(), +// right: buttonInset()) +// button.addTarget(self, action:action, for:.touchUpInside) +// button.autoSetDimension(.width, toSize:buttonSize()) +// button.autoSetDimension(.height, toSize:buttonSize()) +// return button +// } +// +// // Creates a row containing a given set of subviews. +// func rowWithSubviews(subviews: [UIView]) -> UIView { +// let row = UIView() +// row.setContentHuggingVerticalHigh() +// row.autoSetDimension(.height, toSize:buttonSize()) +// +// if subviews.count > 1 { +// // If there's more than one subview in the row, +// // space them evenly within the row. +// var lastSubview: UIView? +// for subview in subviews { +// row.addSubview(subview) +// subview.setContentHuggingHorizontalHigh() +// subview.autoVCenterInSuperview() +// +// if lastSubview != nil { +// let spacer = UIView() +// spacer.isHidden = true +// row.addSubview(spacer) +// spacer.autoPinEdge(.left, to:.right, of:lastSubview!) +// spacer.autoPinEdge(.right, to:.left, of:subview) +// spacer.setContentHuggingHorizontalLow() +// spacer.autoVCenterInSuperview() +// +// if subviews.count == 2 { +// // special case to hardcode the spacer's size when there is only 1 spacer. +// spacer.autoSetDimension(.width, toSize: ScaleFromIPhone5To7Plus(46, 60)) +// } else { +// spacer.autoSetDimension(.width, toSize: ScaleFromIPhone5To7Plus(3, 5)) +// } +// } +// +// lastSubview = subview +// } +// subviews.first!.autoPinEdge(toSuperviewEdge:.left) +// subviews.last!.autoPinEdge(toSuperviewEdge:.right) +// } else if subviews.count == 1 { +// // If there's only one subview in this row, center it. +// let subview = subviews.first! +// row.addSubview(subview) +// subview.autoVCenterInSuperview() +// subview.autoPinWidthToSuperview() +// } +// +// return row +// } +// +// // MARK: - Layout +// +// override func updateViewConstraints() { +// if !hasConstraints { +// // We only want to create our constraints once. +// // +// // Note that constraints are also created elsewhere. +// // This only creates the constraints for the top-level contents of the view. +// hasConstraints = true +// +// let topMargin = CGFloat(40) +// let contactHMargin = CGFloat(30) +// let contactVSpacing = CGFloat(3) +// let ongoingHMargin = ScaleFromIPhone5To7Plus(46, 72) +// let incomingHMargin = ScaleFromIPhone5To7Plus(46, 72) +// let settingsNagHMargin = CGFloat(30) +// let ongoingBottomMargin = ScaleFromIPhone5To7Plus(23, 41) +// let incomingBottomMargin = CGFloat(41) +// let settingsNagBottomMargin = CGFloat(41) +// let avatarTopSpacing = ScaleFromIPhone5To7Plus(25, 50) +// // The buttons have built-in 10% margins, so to appear centered +// // the avatar's bottom spacing should be a bit less. +// let avatarBottomSpacing = ScaleFromIPhone5To7Plus(18, 41) +// // Layout of the local video view is a bit unusual because +// // although the view is square, it will be used +// let videoPreviewHMargin = CGFloat(0) +// +// // Dark blurred background. +// blurView.autoPinEdgesToSuperviewEdges() +// +// localVideoView.autoPinEdge(toSuperviewEdge:.right, withInset:videoPreviewHMargin) +// localVideoView.autoPinEdge(toSuperviewEdge:.top, withInset:topMargin) +// let localVideoSize = ScaleFromIPhone5To7Plus(80, 100) +// localVideoView.autoSetDimension(.width, toSize:localVideoSize) +// localVideoView.autoSetDimension(.height, toSize:localVideoSize) +// +// contactNameLabel.autoPinEdge(toSuperviewEdge:.top, withInset:topMargin) +// contactNameLabel.autoPinEdge(toSuperviewEdge:.left, withInset:contactHMargin) +// contactNameLabel.setContentHuggingVerticalHigh() +// +// callStatusLabel.autoPinEdge(.top, to:.bottom, of:contactNameLabel, withOffset:contactVSpacing) +// callStatusLabel.autoPinEdge(toSuperviewEdge:.left, withInset:contactHMargin) +// callStatusLabel.setContentHuggingVerticalHigh() +// +// contactAvatarView.autoPinEdge(.top, to:.bottom, of:callStatusLabel, withOffset:+avatarTopSpacing) +// contactAvatarView.autoPinEdge(.bottom, to:.top, of:ongoingCallView, withOffset:-avatarBottomSpacing) +// contactAvatarView.autoHCenterInSuperview() +// // Stretch that avatar to fill the available space. +// contactAvatarView.setContentHuggingLow() +// contactAvatarView.setCompressionResistanceLow() +// // Preserve square aspect ratio of contact avatar. +// contactAvatarView.autoMatch(.width, to:.height, of:contactAvatarView) +// +// // Ongoing call controls +// ongoingCallView.autoPinEdge(toSuperviewEdge:.bottom, withInset:ongoingBottomMargin) +// ongoingCallView.autoPinWidthToSuperview(withMargin:ongoingHMargin) +// ongoingCallView.setContentHuggingVerticalHigh() +// +// // Incoming call controls +// incomingCallView.autoPinEdge(toSuperviewEdge:.bottom, withInset:incomingBottomMargin) +// incomingCallView.autoPinWidthToSuperview(withMargin:incomingHMargin) +// incomingCallView.setContentHuggingVerticalHigh() +// +// // Settings nag views +// settingsNagView.autoPinEdge(toSuperviewEdge:.bottom, withInset:settingsNagBottomMargin) +// settingsNagView.autoPinWidthToSuperview(withMargin:settingsNagHMargin) +// settingsNagView.autoPinEdge(.top, to:.bottom, of:callStatusLabel) +// } +// +// updateRemoteVideoLayout() +// updateLocalVideoLayout() +// +// super.updateViewConstraints() +// } +// +// internal func updateRemoteVideoLayout() { +// NSLayoutConstraint.deactivate(self.remoteVideoConstraints) +// +// var constraints: [NSLayoutConstraint] = [] +// +// // We fill the screen with the remote video. The remote video's +// // aspect ratio may not (and in fact will very rarely) match the +// // aspect ratio of the current device, so parts of the remote +// // video will be hidden offscreen. +// // +// // It's better to trim the remote video than to adopt a letterboxed +// // layout. +// if remoteVideoSize.width > 0 && remoteVideoSize.height > 0 && +// self.view.bounds.size.width > 0 && self.view.bounds.size.height > 0 { +// +// var remoteVideoWidth = self.view.bounds.size.width +// var remoteVideoHeight = self.view.bounds.size.height +// if remoteVideoSize.width / self.view.bounds.size.width > remoteVideoSize.height / self.view.bounds.size.height { +// remoteVideoWidth = round(self.view.bounds.size.height * remoteVideoSize.width / remoteVideoSize.height) +// } else { +// remoteVideoHeight = round(self.view.bounds.size.width * remoteVideoSize.height / remoteVideoSize.width) +// } +// constraints.append(remoteVideoView.autoSetDimension(.width, toSize:remoteVideoWidth)) +// constraints.append(remoteVideoView.autoSetDimension(.height, toSize:remoteVideoHeight)) +// constraints += remoteVideoView.autoCenterInSuperview() +// +// remoteVideoView.frame = CGRect(origin:CGPoint.zero, +// size:CGSize(width:remoteVideoWidth, +// height:remoteVideoHeight)) +// +// remoteVideoView.isHidden = false +// } else { +// constraints += remoteVideoView.autoPinEdgesToSuperviewEdges() +// remoteVideoView.isHidden = true +// } +// +// self.remoteVideoConstraints = constraints +// +// // We need to force relayout to occur immediately (and not +// // wait for a UIKit layout/render pass) or the remoteVideoView +// // (which presumably is updating its CALayer directly) will +// // ocassionally appear to have bad frames. +// remoteVideoView.setNeedsLayout() +// remoteVideoView.superview?.setNeedsLayout() +// remoteVideoView.layoutIfNeeded() +// remoteVideoView.superview?.layoutIfNeeded() +// +// updateCallUI(callState: call.state) +// } +// +// internal func updateLocalVideoLayout() { +// +// NSLayoutConstraint.deactivate(self.localVideoConstraints) +// +// var constraints: [NSLayoutConstraint] = [] +// +// if localVideoView.isHidden { +// let contactHMargin = CGFloat(30) +// constraints.append(contactNameLabel.autoPinEdge(toSuperviewEdge:.right, withInset:contactHMargin)) +// constraints.append(callStatusLabel.autoPinEdge(toSuperviewEdge:.right, withInset:contactHMargin)) +// } else { +// let spacing = CGFloat(10) +// constraints.append(contactNameLabel.autoPinEdge(.right, to:.left, of:localVideoView, withOffset:-spacing)) +// constraints.append(callStatusLabel.autoPinEdge(.right, to:.left, of:localVideoView, withOffset:-spacing)) +// } +// +// self.localVideoConstraints = constraints +// updateCallUI(callState: call.state) +// } +// +// // MARK: - Methods +// +// func showCallFailed(error: Error) { +// // TODO Show something in UI. +// Logger.error("\(TAG) call failed with error: \(error)") +// } +// +// // MARK: - View State +// +// func localizedTextForCallState(_ callState: CallState) -> String { +// assert(Thread.isMainThread) +// +// switch callState { +// case .idle, .remoteHangup, .localHangup: +// return NSLocalizedString("IN_CALL_TERMINATED", comment: "Call setup status label") +// case .dialing: +// return NSLocalizedString("IN_CALL_CONNECTING", comment: "Call setup status label") +// case .remoteRinging, .localRinging: +// return NSLocalizedString("IN_CALL_RINGING", comment: "Call setup status label") +// case .answering: +// return NSLocalizedString("IN_CALL_SECURING", comment: "Call setup status label") +// case .connected: +// if let call = self.call { +// let callDuration = call.connectionDuration() +// let callDurationDate = Date(timeIntervalSinceReferenceDate:callDuration) +// if dateFormatter == nil { +// dateFormatter = DateFormatter() +// dateFormatter!.dateFormat = "HH:mm:ss" +// dateFormatter!.timeZone = TimeZone(identifier:"UTC")! +// } +// var formattedDate = dateFormatter!.string(from: callDurationDate) +// if formattedDate.hasPrefix("00:") { +// // Don't show the "hours" portion of the date format unless the +// // call duration is at least 1 hour. +// formattedDate = formattedDate.substring(from: formattedDate.index(formattedDate.startIndex, offsetBy: 3)) +// } else { +// // If showing the "hours" portion of the date format, strip any leading +// // zeroes. +// if formattedDate.hasPrefix("0") { +// formattedDate = formattedDate.substring(from: formattedDate.index(formattedDate.startIndex, offsetBy: 1)) +// } +// } +// return formattedDate +// } else { +// return NSLocalizedString("IN_CALL_TALKING", comment: "Call setup status label") +// } +// case .remoteBusy: +// return NSLocalizedString("END_CALL_RESPONDER_IS_BUSY", comment: "Call setup status label") +// case .localFailure: +// if let error = call.error { +// switch error { +// case .timeout(description: _): +// if self.call.direction == .outgoing { +// return NSLocalizedString("CALL_SCREEN_STATUS_NO_ANSWER", comment: "Call setup status label after outgoing call times out") +// } +// default: +// break +// } +// } +// +// return NSLocalizedString("END_CALL_UNCATEGORIZED_FAILURE", comment: "Call setup status label") +// } +// } +// +// func updateCallStatusLabel(callState: CallState) { +// assert(Thread.isMainThread) +// +// let text = String(format: CallStrings.callStatusFormat, +// localizedTextForCallState(callState)) +// self.callStatusLabel.text = text +// } +// +// func updateCallUI(callState: CallState) { +// assert(Thread.isMainThread) +// updateCallStatusLabel(callState: callState) +// +// if isShowingSettingsNag { +// settingsNagView.isHidden = false +// contactAvatarView.isHidden = true +// ongoingCallView.isHidden = true +// return +// } +// +// audioModeMuteButton.isSelected = call.isMuted +// videoModeMuteButton.isSelected = call.isMuted +// audioModeVideoButton.isSelected = call.hasLocalVideo +// videoModeVideoButton.isSelected = call.hasLocalVideo +// speakerPhoneButton.isSelected = call.isSpeakerphoneEnabled +// +// // Show Incoming vs. Ongoing call controls +// let isRinging = callState == .localRinging +// incomingCallView.isHidden = !isRinging +// incomingCallView.isUserInteractionEnabled = isRinging +// ongoingCallView.isHidden = isRinging +// ongoingCallView.isUserInteractionEnabled = !isRinging +// +// // Rework control state if remote video is available. +// let hasRemoteVideo = !remoteVideoView.isHidden +// contactAvatarView.isHidden = hasRemoteVideo +// +// // Rework control state if local video is available. +// let hasLocalVideo = !localVideoView.isHidden +// for subview in [speakerPhoneButton, audioModeMuteButton, audioModeVideoButton] { +// subview?.isHidden = hasLocalVideo +// } +// for subview in [videoModeMuteButton, videoModeVideoButton] { +// subview?.isHidden = !hasLocalVideo +// } +// +// // Also hide other controls if user has tapped to hide them. +// if shouldRemoteVideoControlsBeHidden && !remoteVideoView.isHidden { +// contactNameLabel.isHidden = true +// callStatusLabel.isHidden = true +// ongoingCallView.isHidden = true +// } else { +// contactNameLabel.isHidden = false +// callStatusLabel.isHidden = false +// } +// +// // Dismiss Handling +// switch callState { +// case .remoteHangup, .remoteBusy, .localFailure: +// Logger.debug("\(TAG) dismissing after delay because new state is \(callState)") +// dismissIfPossible(shouldDelay:true) +// case .localHangup: +// Logger.debug("\(TAG) dismissing immediately from local hangup") +// dismissIfPossible(shouldDelay:false) +// default: break +// } +// +// if callState == .connected { +// if callDurationTimer == nil { +// let kDurationUpdateFrequencySeconds = 1 / 20.0 +// callDurationTimer = Timer.scheduledTimer(timeInterval: kDurationUpdateFrequencySeconds, +// target:self, +// selector:#selector(updateCallDuration), +// userInfo:nil, +// repeats:true) +// } +// } else { +// callDurationTimer?.invalidate() +// callDurationTimer = nil +// } +// } +// +// func updateCallDuration(timer: Timer?) { +// updateCallStatusLabel(callState: call.state) +// } +// +// // MARK: - Actions +// +// /** +// * Ends a connected call. Do not confuse with `didPressDeclineCall`. +// */ +// func didPressHangup(sender: UIButton) { +// Logger.info("\(TAG) called \(#function)") +// if let call = self.call { +// callUIAdapter.localHangupCall(call) +// } else { +// Logger.warn("\(TAG) hung up, but call was unexpectedly nil") +// } +// +// dismissIfPossible(shouldDelay:false) +// } +// +// func didPressMute(sender muteButton: UIButton) { +// Logger.info("\(TAG) called \(#function)") +// muteButton.isSelected = !muteButton.isSelected +// if let call = self.call { +// callUIAdapter.setIsMuted(call: call, isMuted: muteButton.isSelected) +// } else { +// Logger.warn("\(TAG) pressed mute, but call was unexpectedly nil") +// } +// } +// +// func didPressSpeakerphone(sender speakerphoneButton: UIButton) { +// Logger.info("\(TAG) called \(#function)") +// speakerphoneButton.isSelected = !speakerphoneButton.isSelected +// if let call = self.call { +// callUIAdapter.setIsSpeakerphoneEnabled(call: call, isEnabled: speakerphoneButton.isSelected) +// } else { +// Logger.warn("\(TAG) pressed mute, but call was unexpectedly nil") +// } +// } +// +// func didPressTextMessage(sender speakerphoneButton: UIButton) { +// Logger.info("\(TAG) called \(#function)") +// +// dismissIfPossible(shouldDelay:false) +// } +// +// func didPressAnswerCall(sender: UIButton) { +// Logger.info("\(TAG) called \(#function)") +// +// guard let call = self.call else { +// Logger.error("\(TAG) call was unexpectedly nil. Terminating call.") +// +// let text = String(format: CallStrings.callStatusFormat, +// NSLocalizedString("END_CALL_UNCATEGORIZED_FAILURE", comment: "Call setup status label")) +// self.callStatusLabel.text = text +// +// dismissIfPossible(shouldDelay:true) +// return +// } +// +// callUIAdapter.answerCall(call) +// } +// +// func didPressVideo(sender: UIButton) { +// Logger.info("\(TAG) called \(#function)") +// let hasLocalVideo = !sender.isSelected +// if let call = self.call { +// callUIAdapter.setHasLocalVideo(call: call, hasLocalVideo: hasLocalVideo) +// } else { +// Logger.warn("\(TAG) pressed video, but call was unexpectedly nil") +// } +// } +// +// /** +// * Denies an incoming not-yet-connected call, Do not confuse with `didPressHangup`. +// */ +// func didPressDeclineCall(sender: UIButton) { +// Logger.info("\(TAG) called \(#function)") +// +// if let call = self.call { +// callUIAdapter.declineCall(call) +// } else { +// Logger.warn("\(TAG) denied call, but call was unexpectedly nil") +// } +// +// dismissIfPossible(shouldDelay:false) +// } +// +// func didPressShowCallSettings(sender: UIButton) { +// Logger.info("\(TAG) called \(#function)") +// +// markSettingsNagAsComplete() +// +// dismissIfPossible(shouldDelay: false, ignoreNag: true, completion: { +// // Find the frontmost presented UIViewController from which to present the +// // settings views. +// let fromViewController = UIApplication.shared.frontmostViewController +// assert(fromViewController != nil) +// +// // Construct the "settings" view & push the "privacy settings" view. +// let navigationController = UIStoryboard.main.instantiateViewController(withIdentifier: "SettingsNavigationController") as! UINavigationController +// assert(navigationController.viewControllers.count == 1) +// let privacySettingsViewController = PrivacySettingsTableViewController() +// navigationController.pushViewController(privacySettingsViewController, animated:false) +// +// fromViewController?.present(navigationController, animated: true, completion: nil) +// }) +// } +// +// func didPressDismissNag(sender: UIButton) { +// Logger.info("\(TAG) called \(#function)") +// +// markSettingsNagAsComplete() +// +// dismissIfPossible(shouldDelay: false, ignoreNag: true) +// } +// +// // We only show the "blocking" settings nag until the user has chosen +// // to view the privacy settings _or_ dismissed the nag at least once. +// // +// // In either case, we set the "CallKit enabled" and "CallKit privacy enabled" +// // settings to their default values to indicate that the user has reviewed +// // them. +// private func markSettingsNagAsComplete() { +// Logger.info("\(TAG) called \(#function)") +// +// let preferences = Environment.getCurrent().preferences! +// +// preferences.setIsCallKitEnabled(preferences.isCallKitEnabled()) +// preferences.setIsCallKitPrivacyEnabled(preferences.isCallKitPrivacyEnabled()) +// } +// +// // MARK: - CallObserver +// +// internal func stateDidChange(call: SignalCall, state: CallState) { +// AssertIsOnMainThread() +// Logger.info("\(self.TAG) new call status: \(state)") +// self.updateCallUI(callState: state) +// } +// +// internal func hasLocalVideoDidChange(call: SignalCall, hasLocalVideo: Bool) { +// AssertIsOnMainThread() +// self.updateCallUI(callState: call.state) +// } +// +// internal func muteDidChange(call: SignalCall, isMuted: Bool) { +// AssertIsOnMainThread() +// self.updateCallUI(callState: call.state) +// } +// +// internal func speakerphoneDidChange(call: SignalCall, isEnabled: Bool) { +// AssertIsOnMainThread() +// self.updateCallUI(callState: call.state) +// } +// +// // MARK: - Video +// +// internal func updateLocalVideoTrack(localVideoTrack: RTCVideoTrack?) { +// AssertIsOnMainThread() +// guard self.localVideoTrack != localVideoTrack else { +// return +// } +// +// self.localVideoTrack = localVideoTrack +// +// var source: RTCAVFoundationVideoSource? +// if localVideoTrack?.source is RTCAVFoundationVideoSource { +// source = localVideoTrack?.source as! RTCAVFoundationVideoSource +// } +// localVideoView.captureSession = source?.captureSession +// let isHidden = source == nil +// Logger.info("\(TAG) \(#function) isHidden: \(isHidden)") +// localVideoView.isHidden = isHidden +// +// updateLocalVideoLayout() +// } +// +// internal func updateRemoteVideoTrack(remoteVideoTrack: RTCVideoTrack?) { +// AssertIsOnMainThread() +// guard self.remoteVideoTrack != remoteVideoTrack else { +// return +// } +// +// self.remoteVideoTrack?.remove(remoteVideoView) +// self.remoteVideoTrack = nil +// remoteVideoView.renderFrame(nil) +// self.remoteVideoTrack = remoteVideoTrack +// self.remoteVideoTrack?.add(remoteVideoView) +// shouldRemoteVideoControlsBeHidden = false +// +// if remoteVideoTrack == nil { +// remoteVideoSize = CGSize.zero +// } +// +// updateRemoteVideoLayout() +// } +// +// internal func dismissIfPossible(shouldDelay: Bool, ignoreNag: Bool = false, completion: (() -> Swift.Void)? = nil) { +// if hasDismissed { +// // Don't dismiss twice. +// return +// } else if !ignoreNag && +// call.direction == .incoming && +// UIDevice.current.supportsCallKit && +// (!Environment.getCurrent().preferences.isCallKitEnabled() || +// Environment.getCurrent().preferences.isCallKitPrivacyEnabled()) { +// +// isShowingSettingsNag = true +// +// // Update the nag view's copy to reflect the settings state. +// if Environment.getCurrent().preferences.isCallKitEnabled() { +// settingsNagDescriptionLabel.text = NSLocalizedString("CALL_VIEW_SETTINGS_NAG_DESCRIPTION_PRIVACY", +// comment: "Reminder to the user of the benefits of disabling CallKit privacy.") +// } else { +// settingsNagDescriptionLabel.text = NSLocalizedString("CALL_VIEW_SETTINGS_NAG_DESCRIPTION_ALL", +// comment: "Reminder to the user of the benefits of enabling CallKit and disabling CallKit privacy.") +// } +// settingsNagDescriptionLabel.superview?.setNeedsLayout() +// +// if Environment.getCurrent().preferences.isCallKitEnabledSet() || +// Environment.getCurrent().preferences.isCallKitPrivacySet() { +// // User has already touched these preferences, only show +// // the "fleeting" nag, not the "blocking" nag. +// +// // Show nag for N seconds. +// DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in +// guard let strongSelf = self else { return } +// strongSelf.dismissIfPossible(shouldDelay: false, ignoreNag: true) +// } +// } +// } else if shouldDelay { +// hasDismissed = true +// DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in +// guard let strongSelf = self else { return } +// strongSelf.dismiss(animated: true, completion:completion) +// } +// } else { +// hasDismissed = true +// self.dismiss(animated: false, completion:completion) +// } +// } +// +// // MARK: - CallServiceObserver +// +// internal func didUpdateCall(call: SignalCall?) { +// // Do nothing. +// } +// +// internal func didUpdateVideoTracks(localVideoTrack: RTCVideoTrack?, +// remoteVideoTrack: RTCVideoTrack?) { +// AssertIsOnMainThread() +// +// updateLocalVideoTrack(localVideoTrack:localVideoTrack) +// updateRemoteVideoTrack(remoteVideoTrack:remoteVideoTrack) +// } +// +// // MARK: - RTCEAGLVideoViewDelegate +// +// internal func videoView(_ videoView: RTCEAGLVideoView, didChangeVideoSize size: CGSize) { +// AssertIsOnMainThread() +// +// if videoView != remoteVideoView { +// return +// } +// +// Logger.info("\(TAG) \(#function): \(size)") +// +// remoteVideoSize = size +// updateRemoteVideoLayout() +// } +} diff --git a/Signal/src/view controllers/MessageComposeTableViewController.h b/Signal/src/view controllers/MessageComposeTableViewController.h index 788f39ad6e..16ab58dfb2 100644 --- a/Signal/src/view controllers/MessageComposeTableViewController.h +++ b/Signal/src/view controllers/MessageComposeTableViewController.h @@ -7,10 +7,6 @@ // #import -#import -#import -#import -#import #import "Contact.h" #import "LocalizableText.h" diff --git a/Signal/src/view controllers/MessagesViewController.xib b/Signal/src/view controllers/MessagesViewController.xib new file mode 100644 index 0000000000..d4b3087a1f --- /dev/null +++ b/Signal/src/view controllers/MessagesViewController.xib @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Signal/src/view controllers/OWSConversationSettingsTableViewController.m b/Signal/src/view controllers/OWSConversationSettingsTableViewController.m index b5abbe19c0..9b24646eae 100644 --- a/Signal/src/view controllers/OWSConversationSettingsTableViewController.m +++ b/Signal/src/view controllers/OWSConversationSettingsTableViewController.m @@ -93,12 +93,7 @@ static NSString *const OWSConversationSettingsTableViewControllerSegueShowGroupM return self; } - _storageManager = [TSStorageManager sharedManager]; - _contactsManager = [Environment getCurrent].contactsManager; - _messageSender = [[OWSMessageSender alloc] initWithNetworkManager:[Environment getCurrent].networkManager - storageManager:_storageManager - contactsManager:_contactsManager - contactsUpdater:[Environment getCurrent].contactsUpdater]; + [self commonInit]; return self; } @@ -110,14 +105,30 @@ static NSString *const OWSConversationSettingsTableViewControllerSegueShowGroupM return self; } + [self commonInit]; + + return self; +} + +- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil { + self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; + if (!self) { + return self; + } + + [self commonInit]; + + return self; +} + +- (void)commonInit +{ _storageManager = [TSStorageManager sharedManager]; _contactsManager = [Environment getCurrent].contactsManager; _messageSender = [[OWSMessageSender alloc] initWithNetworkManager:[Environment getCurrent].networkManager storageManager:_storageManager contactsManager:_contactsManager contactsUpdater:[Environment getCurrent].contactsUpdater]; - - return self; } - (void)configureWithThread:(TSThread *)thread diff --git a/Signal/src/view controllers/OWSMessagesToolbarContentView.xib b/Signal/src/view controllers/OWSMessagesToolbarContentView.xib new file mode 100644 index 0000000000..45937cf82c --- /dev/null +++ b/Signal/src/view controllers/OWSMessagesToolbarContentView.xib @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Signal/src/view controllers/SignalsViewController.m b/Signal/src/view controllers/SignalsViewController.m index 7c442d6bdf..4fbacad5c8 100644 --- a/Signal/src/view controllers/SignalsViewController.m +++ b/Signal/src/view controllers/SignalsViewController.m @@ -564,8 +564,8 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS - (void)presentThread:(TSThread *)thread keyboardOnViewAppearing:(BOOL)keyboardOnViewAppearing { dispatch_async(dispatch_get_main_queue(), ^{ - MessagesViewController *mvc = [[UIStoryboard storyboardWithName:AppDelegateStoryboardMain bundle:NULL] - instantiateViewControllerWithIdentifier:@"MessagesViewController"]; + MessagesViewController *mvc = [[MessagesViewController alloc] initWithNibName:@"MessagesViewController" + bundle:nil]; if (self.presentedViewController) { [self.presentedViewController dismissViewControllerAnimated:YES completion:nil]; From 27b515ea4580383033df8f1ac4d96d01b73a9cde Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 10 Mar 2017 17:09:37 -0300 Subject: [PATCH 04/11] Add AttachmentApprovalViewController. // FREEBIE --- .../file-icon-large.imageset/Contents.json | 21 + .../file-icon-large@1x.png | Bin 0 -> 25096 bytes Signal/src/Storyboard/Main.storyboard | 10 +- Signal/src/UIColor+OWS.h | 2 + Signal/src/UIColor+OWS.m | 17 + .../AttachmentApprovalViewController.swift | 1126 +++-------------- .../view controllers/MessagesViewController.m | 14 +- .../view controllers/SignalAttachment.swift | 250 ++-- .../translations/en.lproj/Localizable.strings | 15 + 9 files changed, 393 insertions(+), 1062 deletions(-) create mode 100644 Signal/Images.xcassets/file-icon-large.imageset/Contents.json create mode 100644 Signal/Images.xcassets/file-icon-large.imageset/file-icon-large@1x.png diff --git a/Signal/Images.xcassets/file-icon-large.imageset/Contents.json b/Signal/Images.xcassets/file-icon-large.imageset/Contents.json new file mode 100644 index 0000000000..e01e985e34 --- /dev/null +++ b/Signal/Images.xcassets/file-icon-large.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "file-icon-large@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/file-icon-large.imageset/file-icon-large@1x.png b/Signal/Images.xcassets/file-icon-large.imageset/file-icon-large@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..7adc6fe64a9c9d5043b9c10dcaed3c699bef2ddc GIT binary patch literal 25096 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

?Q%Ll z^D8Ig8Bm*L`0gFam{_JGl?wZnZ$8?`bKqc+QsiEe`R}qaPeqiKbZebeLLgsua$Cmg zNISXg82w61>MTj+sT&gKhMLgh(7e_ag+3g0YuK{k>HjD~)cbw$aeS}Z)&v0v^)lIm z#Ye31c~HK&&3OZh@Q22}f745ECV?cYnwgmu98_Nw(UiUldbUOQ`{!k!ofjKC3S#Mx zlUO3zcng&F3fN=}X|_(Hzvy8M{Ygg!$H+dQKy1{Hn-m928Cl>+-vmQzgF{V+&azUp zsbq2eb;V3?$>wkn+~hIyfqBMxP1>4N77NN^LD{2LygE^K`DLc)?JVpfi#{TV0iXU`089c= zR@cf_oV=(~2F$ksqYM(p0?Hs!28rVAMStkcDhx!FnM4^R${}9K2*{KT#Bn2PCw=e^z^6+blcr$7_nO<6mYyEnFYSw*FnT z7srJMW8F1BXH9NvJ(|X}F6f=urjkX-FAw>OKiVcm;&k{;=($!ayuLcrWWsOA{ZEeU zD35D=gp<0+FJo7~cV73o&LQ`~MXq1Bk1w%a&@|jug*=n`g82;#sTIXs9&&J!=@=%u zq+dner`jKR+qqWpYwk4ph&BjO@tu?T3ft2kw&++O`x=H8$iBcV=cw;6HQ~&=Q3@l| zy;IVULbenIer>{ogf8-(=Z)UK2S6h`990U*ys7#>Zpohbj(6=tSIPNG3Zh|XkHpfi z(g}ZXEpI3mjeWLB-B_D@_Ko6?4gvxU@tl>lUj(=M{+4auc>Jo7@>TfB0jM1ORWvhb iQkvy|lUWk9OVRilljc6UPd!86-;8Nqu0>OMtN#mwzZiW0 literal 0 HcmV?d00001 diff --git a/Signal/src/Storyboard/Main.storyboard b/Signal/src/Storyboard/Main.storyboard index 47cff25a39..3308a70426 100644 --- a/Signal/src/Storyboard/Main.storyboard +++ b/Signal/src/Storyboard/Main.storyboard @@ -1545,11 +1545,11 @@ - + - +