diff --git a/BUILDING.md b/BUILDING.md index 86d8e3a69c..8342d2f7c0 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -17,8 +17,21 @@ occasionally, CocoaPods itself will need to be updated. Do this with sudo gem update ``` -3) Open the `Signal.xcworkspace` in Xcode. Build and Run and you are ready to go! +3) Open the `Signal.xcworkspace` in Xcode. + +``` +open Signal.xcworkspace +``` + +4) Some of our build scripts, like running tests, expect your Derived +Data directory to be `$(PROJECT_DIR)/build`. In Xcode, go to `Preferences-> Locations`, +and set the "Derived Data" dropdown to "Relative" and the text field +value to "build". + +5) Build and Run and you are ready to go! ## Known issues -Features related to push notifications are known to be not working for third-party contributors since Apple's Push Notification service pushs will only work with Open Whisper Systems production code signing certificate. \ No newline at end of file +Features related to push notifications are known to be not working for third-party contributors since Apple's Push Notification service pushs will only work with Open Whisper Systems production code signing certificate. + + diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 0e42bef81c..0292272a5d 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 45843D1F1D2236B30013E85A /* OWSContactsSearcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 45843D1E1D2236B30013E85A /* OWSContactsSearcher.m */; }; 45843D201D2236B30013E85A /* OWSContactsSearcher.m in Sources */ = {isa = PBXBuildFile; fileRef = 45843D1E1D2236B30013E85A /* OWSContactsSearcher.m */; }; 45843D221D223BA10013E85A /* OWSContactsSearcherTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 45843D211D223BA10013E85A /* OWSContactsSearcherTest.m */; }; + 459C3F0D1C9B3A1B003ACF51 /* TSMessageAdapterTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 459C3F0C1C9B3A1B003ACF51 /* TSMessageAdapterTest.m */; }; 45C681B71D305A580050903A /* OWSCall.m in Sources */ = {isa = PBXBuildFile; fileRef = 45C681B61D305A580050903A /* OWSCall.m */; }; 45C681B81D305A580050903A /* OWSCall.m in Sources */ = {isa = PBXBuildFile; fileRef = 45C681B61D305A580050903A /* OWSCall.m */; }; 45C681BC1D305C080050903A /* OWSCallCollectionViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 45C681BA1D305C080050903A /* OWSCallCollectionViewCell.m */; }; @@ -503,6 +504,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 4526BD481CA61C8D00166BC8 /* OWSMessageEditing.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageEditing.h; sourceTree = ""; }; 453CC0361D08E1A60040EBA3 /* sn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sn; path = translations/sn.lproj/Localizable.strings; sourceTree = ""; }; 453D28AF1D32B87100D523F0 /* OWSErrorMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSErrorMessage.h; sourceTree = ""; }; 453D28B01D32B87100D523F0 /* OWSErrorMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSErrorMessage.m; sourceTree = ""; }; @@ -516,6 +518,7 @@ 45843D1D1D2236B30013E85A /* OWSContactsSearcher.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSContactsSearcher.h; sourceTree = ""; }; 45843D1E1D2236B30013E85A /* OWSContactsSearcher.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSContactsSearcher.m; sourceTree = ""; }; 45843D211D223BA10013E85A /* OWSContactsSearcherTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSContactsSearcherTest.m; sourceTree = ""; }; + 459C3F0C1C9B3A1B003ACF51 /* TSMessageAdapterTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TSMessageAdapterTest.m; path = "view controllers/Signals/TSMessageAdapters/TSMessageAdapterTest.m"; sourceTree = ""; }; 45C681B51D305A580050903A /* OWSCall.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSCall.h; sourceTree = ""; }; 45C681B61D305A580050903A /* OWSCall.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSCall.m; sourceTree = ""; }; 45C681B91D305C080050903A /* OWSCallCollectionViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSCallCollectionViewCell.h; sourceTree = ""; }; @@ -1135,6 +1138,14 @@ path = Models; sourceTree = ""; }; + 459C3F0E1C9B3A20003ACF51 /* TSMessageAdapters */ = { + isa = PBXGroup; + children = ( + 459C3F0C1C9B3A1B003ACF51 /* TSMessageAdapterTest.m */, + ); + name = TSMessageAdapters; + sourceTree = ""; + }; 70B8009F190C529C0042E3F0 /* Products */ = { isa = PBXGroup; children = ( @@ -1736,6 +1747,7 @@ B62D53F51A23CCAD009AAF82 /* TSMessageAdapter.h */, B62D53F61A23CCAD009AAF82 /* TSMessageAdapter.m */, B6D3CBCE1C1376BE00C039DF /* TSContentAdapters.h */, + 4526BD481CA61C8D00166BC8 /* OWSMessageEditing.h */, ); name = TSMessageAdapters; path = TSMessageAdapaters; @@ -1794,6 +1806,7 @@ isa = PBXGroup; children = ( 457F3AB01D1470CF00C51351 /* view controllers */, + 459C3F0E1C9B3A20003ACF51 /* TSMessageAdapters */, B660F66D1C29867F00687D6E /* audio */, B660F6731C29867F00687D6E /* call */, B660F6751C29867F00687D6E /* contact */, @@ -2789,6 +2802,7 @@ B660F7051C29988E00687D6E /* EncodedAudioFrame.m in Sources */, B660F7061C29988E00687D6E /* EncodedAudioPacket.m in Sources */, B660F7071C29988E00687D6E /* AudioProcessor.m in Sources */, + 459C3F0D1C9B3A1B003ACF51 /* TSMessageAdapterTest.m in Sources */, B660F7081C29988E00687D6E /* AudioStretcher.m in Sources */, B660F7091C29988E00687D6E /* DesiredBufferDepthController.m in Sources */, B660F70A1C29988E00687D6E /* DropoutTracker.m in Sources */, @@ -3298,7 +3312,6 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)", - "$(PROJECT_DIR)/build/Debug-iphoneos", ); OTHER_LDFLAGS = ( "-all_load", @@ -3351,7 +3364,6 @@ LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)", - "$(PROJECT_DIR)/build/Debug-iphoneos", ); OTHER_LDFLAGS = ( "-all_load", diff --git a/Signal/Signal-Info.plist b/Signal/Signal-Info.plist index bbe052ff1a..e310b2ad90 100644 --- a/Signal/Signal-Info.plist +++ b/Signal/Signal-Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.3.6 + 2.4.0 CFBundleSignature ???? CFBundleURLTypes @@ -38,7 +38,7 @@ CFBundleVersion - 2.3.6.0 + 2.4.0 ITSAppUsesNonExemptEncryption LOGS_EMAIL diff --git a/Signal/src/Models/TSMessageAdapaters/OWSMessageEditing.h b/Signal/src/Models/TSMessageAdapaters/OWSMessageEditing.h new file mode 100644 index 0000000000..ab10bc00cb --- /dev/null +++ b/Signal/src/Models/TSMessageAdapaters/OWSMessageEditing.h @@ -0,0 +1,8 @@ +// Copyright © 2016 Open Whisper Systems. All rights reserved. + +@protocol OWSMessageEditing + +- (BOOL)canPerformEditingAction:(SEL)action; +- (void)performEditingAction:(SEL)action; + +@end diff --git a/Signal/src/Models/TSMessageAdapaters/TSAnimatedAdapter.h b/Signal/src/Models/TSMessageAdapaters/TSAnimatedAdapter.h index 1cd5fba7c5..3eb21f78b3 100644 --- a/Signal/src/Models/TSMessageAdapaters/TSAnimatedAdapter.h +++ b/Signal/src/Models/TSMessageAdapaters/TSAnimatedAdapter.h @@ -6,11 +6,12 @@ // Copyright (c) 2015 Open Whisper Systems. All rights reserved. // -#import +#import "OWSMessageEditing.h" #import -#import "TSAttachmentStream.h" -@interface TSAnimatedAdapter : JSQMediaItem +@class TSAttachmentStream; + +@interface TSAnimatedAdapter : JSQMediaItem - (instancetype)initWithAttachment:(TSAttachmentStream *)attachment; @@ -19,5 +20,6 @@ - (BOOL)isVideo; @property NSString *attachmentId; +@property NSData *fileData; @end diff --git a/Signal/src/Models/TSMessageAdapaters/TSAnimatedAdapter.m b/Signal/src/Models/TSMessageAdapaters/TSAnimatedAdapter.m index 8faad73351..a7976d3bae 100644 --- a/Signal/src/Models/TSMessageAdapaters/TSAnimatedAdapter.m +++ b/Signal/src/Models/TSMessageAdapaters/TSAnimatedAdapter.m @@ -8,13 +8,15 @@ #import "TSAnimatedAdapter.h" #import "FLAnimatedImage.h" +#import "TSAttachmentStream.h" #import "UIDevice+TSHardwareVersion.h" +#import #import +#import @interface TSAnimatedAdapter () @property (strong, nonatomic) UIImageView *cachedImageView; -@property (strong, nonatomic) NSData *fileData; @property (strong, nonatomic) UIImage *image; @property (strong, nonatomic) TSAttachmentStream *attachment; @@ -92,6 +94,36 @@ return NO; } +#pragma mark - OWSMessageEditing Protocol + +- (BOOL)canPerformEditingAction:(SEL)action +{ + return (action == @selector(copy:) || action == NSSelectorFromString(@"save:")); +} + +- (void)performEditingAction:(SEL)action +{ + if (action == @selector(copy:)) { + UIPasteboard *pasteBoard = UIPasteboard.generalPasteboard; + [pasteBoard setData:self.fileData forPasteboardType:(__bridge NSString *)kUTTypeGIF]; + } else if (action == NSSelectorFromString(@"save:")) { + NSData *photoData = self.fileData; + ALAssetsLibrary *library = [[ALAssetsLibrary alloc] init]; + [library writeImageDataToSavedPhotosAlbum:photoData + metadata:nil + completionBlock:^(NSURL *assetURL, NSError *error) { + if (error) { + DDLogWarn(@"Error Saving image to photo album: %@", error); + } + }]; + } else { + // Shouldn't get here, as only supported actions should be exposed via canPerformEditingAction + NSString *actionString = NSStringFromSelector(action); + DDLogError(@"'%@' action unsupported for %@: attachmentId=%@", actionString, [self class], self.attachmentId); + } +} + + #pragma mark - Utility - (CGSize)getBubbleSizeForImage:(UIImage *)image { diff --git a/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.h b/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.h index bf660d4d35..663d92b4f5 100644 --- a/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.h +++ b/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.h @@ -6,11 +6,11 @@ // Copyright (c) 2014 Open Whisper Systems. All rights reserved. // -#import +#import "OWSMessageEditing.h" #import -#import "TSInteraction.h" -#import "TSMessageAdapter.h" -#import "TSThread.h" + +@class TSInteraction; +@class TSThread; #define ME_MESSAGE_IDENTIFIER @"Me"; @@ -24,10 +24,11 @@ typedef NS_ENUM(NSInteger, TSMessageAdapterType) { TSGenericTextMessageAdapter, // Used when message direction is unknown (outgoing or incoming) }; -@interface TSMessageAdapter : NSObject +@interface TSMessageAdapter : NSObject + (id)messageViewDataWithInteraction:(TSInteraction *)interaction inThread:(TSThread *)thread; +@property TSInteraction *interaction; @property TSMessageAdapterType messageType; @end diff --git a/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.m b/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.m index c5f0fe3896..d97c9a0893 100644 --- a/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.m +++ b/Signal/src/Models/TSMessageAdapaters/TSMessageAdapter.m @@ -1,19 +1,21 @@ -// // TSMessageAdapter.m +// // Signal // // Created by Frederic Jacobs on 24/11/14. // Copyright (c) 2014 Open Whisper Systems. All rights reserved. // -#import "TSAttachmentPointer.h" -#import "TSCall.h" #import "OWSCall.h" +#import "TSAttachmentPointer.h" +#import "TSAttachmentStream.h" +#import "TSCall.h" #import "TSContentAdapters.h" #import "TSErrorMessage.h" #import "TSIncomingMessage.h" #import "TSInfoMessage.h" #import "TSOutgoingMessage.h" +#import @interface TSMessageAdapter () @@ -41,7 +43,7 @@ // for MediaMessages -@property JSQMediaItem *mediaItem; +@property JSQMediaItem *mediaItem; // --- @@ -57,6 +59,7 @@ + (id)messageViewDataWithInteraction:(TSInteraction *)interaction inThread:(TSThread *)thread { TSMessageAdapter *adapter = [[TSMessageAdapter alloc] init]; + adapter.interaction = interaction; adapter.messageDate = interaction.date; // TODO casting a string to an integer? At least need a comment here explaining why we are doing this. adapter.identifier = (NSUInteger)interaction.uniqueId; @@ -236,6 +239,57 @@ return self.messageDate; } +#pragma mark - OWSMessageEditing Protocol + +- (BOOL)canPerformEditingAction:(SEL)action +{ + + // Deletes are always handled by TSMessageAdapter + if (action == @selector(delete:)) { + return YES; + } + + // Delegate other actions for media items + if (self.isMediaMessage) { + return [self.mediaItem canPerformEditingAction:action]; + } else { + // Text message - no media attachment + if (action == @selector(copy:)) { + return YES; + } + } + return NO; +} + +- (void)performEditingAction:(SEL)action +{ + // Deletes are always handled by TSMessageAdapter + if (action == @selector(delete:)) { + DDLogDebug(@"Deleting interaction with uniqueId: %@", self.interaction.uniqueId); + [self.interaction remove]; + return; + } + + // Delegate other actions for media items + if (self.isMediaMessage) { + [self.mediaItem performEditingAction:action]; + return; + } else { + // Text message - no media attachment + if (action == @selector(copy:)) { + UIPasteboard.generalPasteboard.string = self.messageBody; + return; + } + } + + // Shouldn't get here, as only supported actions should be exposed via canPerformEditingAction + NSString *actionString = NSStringFromSelector(action); + DDLogError(@"'%@' action unsupported for TSInteraction: uniqueId=%@, mediaType=%@", + actionString, + self.interaction.uniqueId, + [self.mediaItem class]); +} + - (BOOL)isMediaMessage { return _mediaItem ? YES : NO; } diff --git a/Signal/src/Models/TSMessageAdapaters/TSPhotoAdapter.h b/Signal/src/Models/TSMessageAdapaters/TSPhotoAdapter.h index 533a083cfb..f59525eaad 100644 --- a/Signal/src/Models/TSMessageAdapaters/TSPhotoAdapter.h +++ b/Signal/src/Models/TSMessageAdapaters/TSPhotoAdapter.h @@ -1,17 +1,20 @@ // Created by Frederic Jacobs on 17/12/14. // Copyright (c) 2014 Open Whisper Systems. All rights reserved. -#import +#import "OWSMessageEditing.h" #import -#import "TSAttachmentStream.h" -@interface TSPhotoAdapter : JSQPhotoMediaItem +@class TSAttachmentStream; + +@interface TSPhotoAdapter : JSQPhotoMediaItem - (instancetype)initWithAttachment:(TSAttachmentStream *)attachment; - (BOOL)isImage; - (BOOL)isAudio; - (BOOL)isVideo; + +@property TSAttachmentStream *attachment; @property NSString *attachmentId; @end diff --git a/Signal/src/Models/TSMessageAdapaters/TSPhotoAdapter.m b/Signal/src/Models/TSMessageAdapaters/TSPhotoAdapter.m index fb436b0f06..5df6e51063 100644 --- a/Signal/src/Models/TSMessageAdapaters/TSPhotoAdapter.m +++ b/Signal/src/Models/TSMessageAdapaters/TSPhotoAdapter.m @@ -2,6 +2,7 @@ // Copyright (c) 2014 Open Whisper Systems. All rights reserved. #import "TSPhotoAdapter.h" +#import "TSAttachmentStream.h" #import "UIDevice+TSHardwareVersion.h" #import @@ -15,10 +16,14 @@ - (instancetype)initWithAttachment:(TSAttachmentStream *)attachment { self = [super initWithImage:attachment.image]; - if (self) { - _cachedImageView = nil; - _attachmentId = attachment.uniqueId; + if (!self) { + return self; } + + _cachedImageView = nil; + _attachment = attachment; + _attachmentId = attachment.uniqueId; + return self; } @@ -71,6 +76,28 @@ return NO; } +#pragma mark - OWSMessageEditing Protocol + +- (BOOL)canPerformEditingAction:(SEL)action +{ + return (action == @selector(copy:) || action == NSSelectorFromString(@"save:")); +} + +- (void)performEditingAction:(SEL)action +{ + if (action == @selector(copy:)) { + UIPasteboard.generalPasteboard.image = self.image; + return; + } else if (action == NSSelectorFromString(@"save:")) { + UIImageWriteToSavedPhotosAlbum(self.image, nil, nil, nil); + return; + } + + // Shouldn't get here, as only supported actions should be exposed via canPerformEditingAction + NSString *actionString = NSStringFromSelector(action); + DDLogError(@"'%@' action unsupported for %@: attachmentId=%@", actionString, self.class, self.attachmentId); +} + #pragma mark - Utility - (CGSize)getBubbleSizeForImage:(UIImage *)image { diff --git a/Signal/src/Models/TSMessageAdapaters/TSVideoAttachmentAdapter.h b/Signal/src/Models/TSMessageAdapaters/TSVideoAttachmentAdapter.h index 40e2deed48..51ed816d6b 100644 --- a/Signal/src/Models/TSMessageAdapaters/TSVideoAttachmentAdapter.h +++ b/Signal/src/Models/TSMessageAdapaters/TSVideoAttachmentAdapter.h @@ -1,11 +1,12 @@ // Created by Frederic Jacobs on 17/12/14. // Copyright (c) 2014 Open Whisper Systems. All rights reserved. -#import +#import "OWSMessageEditing.h" #import -#import "TSAttachmentStream.h" -@interface TSVideoAttachmentAdapter : JSQVideoMediaItem +@class TSAttachmentStream; + +@interface TSVideoAttachmentAdapter : JSQVideoMediaItem @property NSString *attachmentId; @property (nonatomic, strong) NSString *contentType; diff --git a/Signal/src/Models/TSMessageAdapaters/TSVideoAttachmentAdapter.m b/Signal/src/Models/TSMessageAdapaters/TSVideoAttachmentAdapter.m index e3c54d7a57..69f5541293 100644 --- a/Signal/src/Models/TSMessageAdapaters/TSVideoAttachmentAdapter.m +++ b/Signal/src/Models/TSMessageAdapaters/TSVideoAttachmentAdapter.m @@ -3,10 +3,12 @@ #import "TSVideoAttachmentAdapter.h" #import "MIMETypeUtil.h" +#import "TSAttachmentStream.h" #import "TSMessagesManager.h" #import "TSStorageManager+keyingMaterial.h" #import #import +#import #import #define AUDIO_BAR_HEIGHT 36 @@ -242,4 +244,62 @@ _cachedImageView = nil; } +#pragma mark - OWSMessageEditing Protocol + +- (BOOL)canPerformEditingAction:(SEL)action +{ + if ([self isVideo]) { + return (action == @selector(copy:) || action == NSSelectorFromString(@"save:")); + } else if ([self isAudio]) { + return (action == @selector(copy:)); + } + + NSString *actionString = NSStringFromSelector(action); + DDLogError( + @"Unexpected action: %@ for VideoAttachmentAdapter with contentType: %@", actionString, self.contentType); + return NO; +} + +- (void)performEditingAction:(SEL)action +{ + if ([self isVideo]) { + if (action == @selector(copy:)) { + NSData *data = [NSData dataWithContentsOfURL:self.fileURL]; + [UIPasteboard.generalPasteboard setData:data forPasteboardType:(NSString *)kUTTypeMPEG4]; + return; + } else if (action == NSSelectorFromString(@"save:")) { + if (UIVideoAtPathIsCompatibleWithSavedPhotosAlbum(self.fileURL.path)) { + UISaveVideoAtPathToSavedPhotosAlbum(self.fileURL.path, self, nil, nil); + } else { + DDLogWarn(@"cowardly refusing to save incompatible video attachment"); + } + } + } else if ([self isAudio]) { + if (action == @selector(copy:)) { + NSData *data = [NSData dataWithContentsOfURL:self.fileURL]; + + NSString *pasteboardType = [MIMETypeUtil getSupportedExtensionFromAudioMIMEType:self.contentType]; + [UIPasteboard.generalPasteboard setData:data forPasteboardType:(NSString *)UIPasteboardNameGeneral]; + + if ([pasteboardType isEqualToString:@"mp3"]) { + [UIPasteboard.generalPasteboard setData:data forPasteboardType:(NSString *)kUTTypeMP3]; + } else if ([pasteboardType isEqualToString:@"aiff"]) { + [UIPasteboard.generalPasteboard setData:data + forPasteboardType:(NSString *)kUTTypeAudioInterchangeFileFormat]; + } else if ([pasteboardType isEqualToString:@"m4a"]) { + [UIPasteboard.generalPasteboard setData:data forPasteboardType:(NSString *)kUTTypeMPEG4Audio]; + } else if ([pasteboardType isEqualToString:@"amr"]) { + [UIPasteboard.generalPasteboard setData:data forPasteboardType:@"org.3gpp.adaptive-multi-rate-audio"]; + } else { + [UIPasteboard.generalPasteboard setData:data forPasteboardType:(NSString *)kUTTypeAudio]; + } + } + } else { + // Shouldn't get here, as only supported actions should be exposed via canPerformEditingAction + NSString *actionString = NSStringFromSelector(action); + DDLogError( + @"Unexpected action: %@ for VideoAttachmentAdapter with contentType: %@", actionString, self.contentType); + } +} + @end diff --git a/Signal/src/view controllers/FullImageViewController.m b/Signal/src/view controllers/FullImageViewController.m index 0bf9c2c8eb..6216ec2291 100644 --- a/Signal/src/view controllers/FullImageViewController.m +++ b/Signal/src/view controllers/FullImageViewController.m @@ -7,7 +7,6 @@ // Copyright (c) 2014 Open Whisper Systems. All rights reserved. // -#import #import "DJWActionSheet+OWS.h" #import "FLAnimatedImage.h" #import "FullImageViewController.h" @@ -143,18 +142,6 @@ [self.view addGestureRecognizer:self.doubleTap]; } -- (void)initializeShareButton { - CGFloat buttonRadius = 50.0f; - CGFloat x = 14.0f; - CGFloat y = self.view.bounds.size.height - buttonRadius - 10.0f; - - self.shareButton = [[UIButton alloc] initWithFrame:CGRectMake(x, y, buttonRadius, buttonRadius)]; - [self.shareButton addTarget:self action:@selector(shareButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; - [self.shareButton setImage:[UIImage imageNamed:@"savephoto"] forState:UIControlStateNormal]; - - [self.view addSubview:self.shareButton]; -} - #pragma mark - Gesture Recognizers - (void)imageDoubleTapped:(UITapGestureRecognizer *)doubleTap { @@ -220,7 +207,6 @@ self.scrollView.frame = self.view.bounds; [self.scrollView addSubview:self.imageView]; [self updateLayouts]; - [self initializeShareButton]; self.view.userInteractionEnabled = YES; _isPresenting = NO; }]; @@ -365,52 +351,6 @@ return size.height / size.width; } -#pragma mark - Actions - -- (void)shareButtonTapped:(UIButton *)sender { - [DJWActionSheet showInView:self.view - withTitle:nil - cancelButtonTitle:NSLocalizedString(@"TXT_CANCEL_TITLE", @"") - destructiveButtonTitle:NSLocalizedString(@"TXT_DELETE_TITLE", @"") - otherButtonTitles:@[ - NSLocalizedString(@"CAMERA_ROLL_SAVE_BUTTON", @""), - NSLocalizedString(@"CAMERA_ROLL_COPY_BUTTON", @"") - ] - tapBlock:^(DJWActionSheet *actionSheet, NSInteger tappedButtonIndex) { - if (tappedButtonIndex == actionSheet.cancelButtonIndex) { - } else if (tappedButtonIndex == actionSheet.destructiveButtonIndex) { - __block TSInteraction *interaction = [self interaction]; - [self dismissViewControllerAnimated:YES - completion:^{ - [interaction remove]; - }]; - - } else { - switch (tappedButtonIndex) { - case 0: { - ALAssetsLibrary *library = [[ALAssetsLibrary alloc] init]; - [library writeImageDataToSavedPhotosAlbum:self.fileData - metadata:nil - completionBlock:^(NSURL *assetURL, NSError *error) { - if (error) { - DDLogWarn(@"Error Saving image to photo album: %@", - error); - } - }]; - break; - } - case 1: - [[UIPasteboard generalPasteboard] setImage:self.image]; - break; - default: - DDLogWarn(@"Illegal Action sheet field #%ld <%s>", - (long)tappedButtonIndex, - __PRETTY_FUNCTION__); - break; - } - } - }]; -} #pragma mark - Saving images to Camera Roll diff --git a/Signal/src/view controllers/MessagesViewController.m b/Signal/src/view controllers/MessagesViewController.m index 3a39928d2e..7072a89d5d 100644 --- a/Signal/src/view controllers/MessagesViewController.m +++ b/Signal/src/view controllers/MessagesViewController.m @@ -198,7 +198,10 @@ typedef enum : NSUInteger { [self initializeTextView]; [JSQMessagesCollectionViewCell registerMenuAction:@selector(delete:)]; - self.collectionView.collectionViewLayout.bubbleSizeCalculator = [[OWSMessagesBubblesSizeCalculator alloc] init]; + SEL saveSelector = NSSelectorFromString(@"save:"); + [JSQMessagesCollectionViewCell registerMenuAction:saveSelector]; + [UIMenuController sharedMenuController].menuItems = @[ [[UIMenuItem alloc] initWithTitle:NSLocalizedString(@"EDIT_ITEM_SAVE_ACTION", @"Short name for edit menu item to save contents of media message.") + action:saveSelector] ]; [self initializeCollectionViewLayout]; [self registerCustomMessageNibs]; @@ -531,6 +534,7 @@ typedef enum : NSUInteger { - (void)initializeBubbles { + self.collectionView.collectionViewLayout.bubbleSizeCalculator = [[OWSMessagesBubblesSizeCalculator alloc] init]; JSQMessagesBubbleImageFactory *bubbleFactory = [[JSQMessagesBubbleImageFactory alloc] init]; self.incomingBubbleImageData = [bubbleFactory incomingMessagesBubbleImageWithColor:[UIColor jsq_messageBubbleLightGrayColor]]; self.outgoingBubbleImageData = [bubbleFactory outgoingMessagesBubbleImageWithColor:[UIColor ows_materialBlueColor]]; @@ -701,6 +705,21 @@ typedef enum : NSUInteger { } } +- (BOOL)collectionView:(JSQMessagesCollectionView *)collectionView shouldShowMenuForItemAtIndexPath:(NSIndexPath *)indexPath +{ + if (indexPath == nil) { + DDLogError(@"Aborting shouldShowMenuForItemAtIndexPath because indexPath is nil"); + // Not sure why this is nil, but occasionally it is, which crashes. + return NO; + } + + // JSQM does some setup in super method + [super collectionView:collectionView shouldShowMenuForItemAtIndexPath:indexPath]; + + // Super method returns false for media methods. We want menu for *all* items + return YES; +} + #pragma mark - JSQMessages CollectionView DataSource - (id)collectionView:(JSQMessagesCollectionView *)collectionView @@ -1350,13 +1369,6 @@ typedef enum : NSUInteger { }]; } -- (void)deleteMessageAtIndexPath:(NSIndexPath *)indexPath { - [self.editingDatabaseConnection readWriteWithBlock:^(YapDatabaseReadWriteTransaction *transaction) { - TSInteraction *interaction = [self interactionAtIndexPath:indexPath]; - [interaction removeWithTransaction:transaction]; - }]; -} - - (void)handleErrorMessageTap:(TSErrorMessage *)message { if ([message isKindOfClass:[TSInvalidIdentityKeyErrorMessage class]]) { TSInvalidIdentityKeyErrorMessage *errorMessage = (TSInvalidIdentityKeyErrorMessage *)message; @@ -1813,6 +1825,7 @@ typedef enum : NSUInteger { return message; } +// FIXME DANGER this method doesn't always return TSMessageAdapters - it can also return JSQCall! - (TSMessageAdapter *)messageAtIndexPath:(NSIndexPath *)indexPath { TSInteraction *interaction = [self interactionAtIndexPath:indexPath]; @@ -1923,22 +1936,24 @@ typedef enum : NSUInteger { canPerformAction:(SEL)action forItemAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender { - if (action == @selector(delete:)) { - return YES; + + TSMessageAdapter *messageAdapter = [self messageAtIndexPath:indexPath]; + // HACK make sure method exists before calling since messageAtIndexPath doesn't + // always return TSMessageAdapters - it can also return JSQCall! + if ([messageAdapter respondsToSelector:@selector(canPerformEditingAction:)]) { + return [messageAdapter canPerformEditingAction:action]; + } + else { + return NO; } - return [super collectionView:collectionView canPerformAction:action forItemAtIndexPath:indexPath withSender:sender]; } - (void)collectionView:(UICollectionView *)collectionView performAction:(SEL)action forItemAtIndexPath:(NSIndexPath *)indexPath withSender:(id)sender { - if (action == @selector(delete:)) { - [self deleteMessageAtIndexPath:indexPath]; - } else { - [super collectionView:collectionView performAction:action forItemAtIndexPath:indexPath withSender:sender]; - } + [[self messageAtIndexPath:indexPath] performEditingAction:action]; } - (void)updateGroup { diff --git a/Signal/test/view controllers/Signals/TSMessageAdapters/TSMessageAdapterTest.m b/Signal/test/view controllers/Signals/TSMessageAdapters/TSMessageAdapterTest.m new file mode 100644 index 0000000000..2ed5a8210c --- /dev/null +++ b/Signal/test/view controllers/Signals/TSMessageAdapters/TSMessageAdapterTest.m @@ -0,0 +1,276 @@ +// Copyright © 2016 Open Whisper Systems. All rights reserved. + +#import "TSAttachmentStream.h" +#import "TSContentAdapters.h" +#import "TSInteraction.h" +#import +#import + +static NSString * const kTestingInteractionId = @"some-fake-testing-id"; + +@interface TSMessageAdapter (Testing) + +// expose some private setters for ease of testing setup +@property (nonatomic, retain) NSString *messageBody; +@property JSQMediaItem *mediaItem; + +@end + +@interface TSMessageAdapterTest : XCTestCase + +@property TSMessageAdapter *messageAdapter; +@property TSInteraction *interaction; +@property (readonly) NSData *fakeAudioData; + +@end + +@implementation TSMessageAdapterTest + +- (NSData *)fakeAudioData +{ + NSString *fakeAudioString = @"QmxhY2tiaXJkIFJhdW0gRG90IE1QMw=="; + return [[NSData alloc] initWithBase64EncodedString:fakeAudioString options:0]; +} + +- (NSData *)fakeVideoData +{ + NSString *fakeVideoString = @"RmFrZSBWaWRlbyBEYXRh"; + return [[NSData alloc] initWithBase64EncodedString:fakeVideoString options:0]; +} + +- (void)setUp +{ + [super setUp]; + + self.messageAdapter = [[TSMessageAdapter alloc] init]; + + self.interaction = [[TSInteraction alloc] initWithUniqueId:kTestingInteractionId]; + [self.interaction save]; + self.messageAdapter.interaction = self.interaction; +} + +- (void)tearDown +{ + // Put teardown code here. This method is called after the invocation of each test method in the class. + [super tearDown]; +} + +// Test canPerformAction + +- (void)testCanPerformEditingActionWithNonMediaMessage +{ + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(delete:)]); + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(copy:)]); + + XCTAssertFalse([self.messageAdapter canPerformEditingAction:NSSelectorFromString(@"save:")]); + + //e.g. any other unsupported action + XCTAssertFalse([self.messageAdapter canPerformEditingAction:@selector(paste:)]); +} + +- (void)testCanPerformEditingActionWithPhotoMessage +{ + self.messageAdapter.mediaItem = [[TSPhotoAdapter alloc] init]; + + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(delete:)]); + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(copy:)]); + XCTAssertTrue([self.messageAdapter canPerformEditingAction:NSSelectorFromString(@"save:")]); + + // e.g. any other unsupported action + XCTAssertFalse([self.messageAdapter canPerformEditingAction:@selector(paste:)]); +} + +- (void)testCanPerformEditingActionWithAnimatedMessage +{ + self.messageAdapter.mediaItem = [[TSAnimatedAdapter alloc] init]; + + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(delete:)]); + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(copy:)]); + XCTAssertTrue([self.messageAdapter canPerformEditingAction:NSSelectorFromString(@"save:")]); + + // e.g. any other unsupported action + XCTAssertFalse([self.messageAdapter canPerformEditingAction:@selector(paste:)]); +} + +- (void)testCanPerformEditingActionWithVideoMessage +{ + TSAttachmentStream *videoAttachment = [[TSAttachmentStream alloc] initWithIdentifier:@"fake-video-message" encryptionKey:nil contentType:@"video/mp4"]; + self.messageAdapter.mediaItem = [[TSVideoAttachmentAdapter alloc] initWithAttachment:videoAttachment incoming:NO]; + + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(delete:)]); + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(copy:)]); + XCTAssertTrue([self.messageAdapter canPerformEditingAction:NSSelectorFromString(@"save:")]); + + // e.g. any other unsupported action + XCTAssertFalse([self.messageAdapter canPerformEditingAction:@selector(paste:)]); +} + +- (void)testCanPerformEditingActionWithAudioMessage +{ + TSAttachmentStream *audioAttachment = [[TSAttachmentStream alloc] initWithIdentifier:@"fake-audio-message" encryptionKey:nil contentType:@"audio/mp3"]; + self.messageAdapter.mediaItem = [[TSVideoAttachmentAdapter alloc] initWithAttachment:audioAttachment incoming:NO]; + + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(delete:)]); + XCTAssertTrue([self.messageAdapter canPerformEditingAction:@selector(copy:)]); + + //e.g. Can't save an audio attachment at this time. + XCTAssertFalse([self.messageAdapter canPerformEditingAction:NSSelectorFromString(@"save:")]); + + //e.g. any other unsupported action + XCTAssertFalse([self.messageAdapter canPerformEditingAction:@selector(paste:)]); +} + +// Test Delete + +- (void)testPerformDeleteEditingActionWithNonMediaMessage +{ + XCTAssertNotNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); + [self.messageAdapter performEditingAction:@selector(delete:)]; + XCTAssertNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); +} + +- (void)testPerformDeleteActionWithPhotoMessage +{ + XCTAssertNotNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); + + self.messageAdapter.mediaItem = [[TSPhotoAdapter alloc] init]; + [self.messageAdapter performEditingAction:@selector(delete:)]; + XCTAssertNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); + // TODO assert files are deleted +} + +- (void)testPerformDeleteEditingActionWithAnimatedMessage +{ + XCTAssertNotNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); + + self.messageAdapter.mediaItem = [[TSAnimatedAdapter alloc] init]; + [self.messageAdapter performEditingAction:@selector(delete:)]; + XCTAssertNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); + // TODO assert files are deleted +} + +- (void)testPerformDeleteEditingActionWithVideoMessage +{ + XCTAssertNotNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); + + TSAttachmentStream *videoAttachment = [[TSAttachmentStream alloc] initWithIdentifier:@"fake-video-message" encryptionKey:nil contentType:@"video/mp4"]; + self.messageAdapter.mediaItem = [[TSVideoAttachmentAdapter alloc] initWithAttachment:videoAttachment incoming:NO]; + + [self.messageAdapter performEditingAction:@selector(delete:)]; + XCTAssertNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); + // TODO assert files are deleted +} + +- (void)testPerformDeleteEditingActionWithAudioMessage +{ + XCTAssertNotNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); + + TSAttachmentStream *audioAttachment = [[TSAttachmentStream alloc] initWithIdentifier:@"fake-audio-message" encryptionKey:nil contentType:@"audio/mp3"]; + self.messageAdapter.mediaItem = [[TSVideoAttachmentAdapter alloc] initWithAttachment:audioAttachment incoming:NO]; + + [self.messageAdapter performEditingAction:@selector(delete:)]; + XCTAssertNil([TSInteraction fetchObjectWithUniqueID:kTestingInteractionId]); + // TODO assert files are deleted +} + +// Test Copy + +- (void)testPerformCopyEditingActionWithNonMediaMessage +{ + self.messageAdapter.messageBody = @"My message text"; + [self.messageAdapter performEditingAction:@selector(copy:)]; + XCTAssertEqualObjects(@"My message text", UIPasteboard.generalPasteboard.string); +} + +- (void)testPerformCopyEditingActionWithPhotoMessage +{ + // reset the paste board for clean slate test + UIPasteboard.generalPasteboard.items = @[]; + XCTAssertNil(UIPasteboard.generalPasteboard.image); + + // Grab some random existing image + UIImage *image = [UIImage imageNamed:@"savephoto"]; + TSPhotoAdapter *photoAdapter = [[TSPhotoAdapter alloc] initWithImage:image]; + self.messageAdapter.mediaItem = photoAdapter; + [self.messageAdapter performEditingAction:@selector(copy:)]; + + XCTAssertNotNil(UIPasteboard.generalPasteboard.image); +} + +- (void)testPerformCopyEditingActionWithVideoMessage +{ + // reset the paste board for clean slate test + UIPasteboard.generalPasteboard.items = @[]; + TSAttachmentStream *videoAttachment = [[TSAttachmentStream alloc] initWithIdentifier:@"fake-video" data:self.fakeVideoData key:nil contentType:@"video/mp4"]; + self.messageAdapter.mediaItem = [[TSVideoAttachmentAdapter alloc] initWithAttachment:videoAttachment incoming:YES]; + + [self.messageAdapter performEditingAction:@selector(copy:)]; + + NSData *copiedData = [UIPasteboard.generalPasteboard dataForPasteboardType:(NSString *)kUTTypeMPEG4]; + XCTAssertEqualObjects(self.fakeVideoData, copiedData); +} + +- (void)testPerformCopyEditingActionWithMp3AudioMessage +{ + UIPasteboard.generalPasteboard.items = @[]; + XCTAssertNil([UIPasteboard.generalPasteboard dataForPasteboardType:(NSString *)kUTTypeMP3]); + + TSAttachmentStream *audioAttachment = [[TSAttachmentStream alloc] initWithIdentifier:@"fake-audio-message" data:self.fakeAudioData key:nil contentType:@"audio/mp3"]; + self.messageAdapter.mediaItem = [[TSVideoAttachmentAdapter alloc] initWithAttachment:audioAttachment incoming:NO]; + + [self.messageAdapter performEditingAction:@selector(copy:)]; + XCTAssertEqualObjects(self.fakeAudioData, [UIPasteboard.generalPasteboard dataForPasteboardType:(NSString *)kUTTypeMP3]); +} + +- (void)testPerformCopyEditingActionWithM4aAudioMessage +{ + UIPasteboard.generalPasteboard.items = @[]; + XCTAssertNil([UIPasteboard.generalPasteboard dataForPasteboardType:(NSString *)kUTTypeMPEG4Audio]); + + TSAttachmentStream *audioAttachment = [[TSAttachmentStream alloc] initWithIdentifier:@"fake-audio-message" data:self.fakeAudioData key:nil contentType:@"audio/x-m4a"]; + self.messageAdapter.mediaItem = [[TSVideoAttachmentAdapter alloc] initWithAttachment:audioAttachment incoming:NO]; + + [self.messageAdapter performEditingAction:@selector(copy:)]; + XCTAssertEqualObjects(self.fakeAudioData, [UIPasteboard.generalPasteboard dataForPasteboardType:(NSString *)kUTTypeMPEG4Audio]); +} + +- (void)testPerformCopyEditingActionWithGenericAudioMessage +{ + UIPasteboard.generalPasteboard.items = @[]; + XCTAssertNil([UIPasteboard.generalPasteboard dataForPasteboardType:(NSString *)kUTTypeAudio]); + + TSAttachmentStream *audioAttachment = [[TSAttachmentStream alloc] initWithIdentifier:@"fake-audio-message" data:self.fakeAudioData key:nil contentType:@"audio/wav"]; + self.messageAdapter.mediaItem = [[TSVideoAttachmentAdapter alloc] initWithAttachment:audioAttachment incoming:NO]; + + [self.messageAdapter performEditingAction:@selector(copy:)]; + XCTAssertEqualObjects(self.fakeAudioData, [UIPasteboard.generalPasteboard dataForPasteboardType:(NSString *)kUTTypeAudio]); +} + +// TODO - We don't currenlty have a good way of testing "copy of an animated message attachment" +// We need an attachment with some NSData, which requires getting into the crypto layer, +// which is outside of my realm. +// +// Since you can't currently PASTE images into our version of JSQMessageViewController, I tested this by pasting +// into native Messages client, and verifying the result was animated. +// +//- (void)testPerformCopyActionWithAnimatedMessage +//{ +// // reset the paste board for clean slate test +// UIPasteboard.generalPasteboard.items = @[]; +// XCTAssertNil(UIPasteboard.generalPasteboard.image); +// +// // "some-animated-gif" doesn't exist yet +// NSData *imageData = [[NSData alloc] initWithContentsOfFile:@"some-animated-gif"]; +// //TODO build attachment with imageData +// TSAttachmentStream animatedAttachement = [[TSAttachmentStream alloc] initWithIdentifier:@"test-animated-attachment-id" data:imageDatq key:@"TODO" contentType:@"image/gif"]; +// TSAnimatedAdapter *animatedAdapter = [[TSAnimatedAdapter alloc] initWithAttachment:animatedAttachment]; +// animatedAdapter.image = image; +// self.messageAdapter.mediaItem = animatedAdapter; +// [self.messageAdapter performEditingAction:@selector(copy:)]; +// +// // TODO XCTAssert that image is copied as a GIF (e.g. not convereted to a PNG, etc.) +// // We want to be sure that we can copy/paste an animated GIF from +// // one thread to the other, and ensure it's still animated. +//} + +@end diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index f4843c94ec..7cd09afeff 100644 Binary files a/Signal/translations/en.lproj/Localizable.strings and b/Signal/translations/en.lproj/Localizable.strings differ