From 9db3b0db273344780a68018256bb0e4a0a4cde13 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Fri, 11 Mar 2016 10:34:39 -0800 Subject: [PATCH] Consistent and efficient media Delete/Copy/Save UX copy/save/delete is accessed via longpress for all media messages, just like for simple text messages. Notes ----- We don't support saving audio attachments as it's not clear where they should go. (I don't think users expect them to end up in their iTunes library.) There is still no UX for "pasting" media into Signal. Removed the now redundant (and confusing) "share" button interface. //FREEBIE --- BUILDING.md | 17 +- Signal.xcodeproj/project.pbxproj | 16 +- .../TSMessageAdapaters/OWSMessageEditing.h | 8 + .../TSMessageAdapaters/TSAnimatedAdapter.h | 8 +- .../TSMessageAdapaters/TSAnimatedAdapter.m | 34 ++- .../TSMessageAdapaters/TSMessageAdapter.h | 11 +- .../TSMessageAdapaters/TSMessageAdapter.m | 60 +++- .../TSMessageAdapaters/TSPhotoAdapter.h | 9 +- .../TSMessageAdapaters/TSPhotoAdapter.m | 33 ++- .../TSVideoAttachmentAdapter.h | 7 +- .../TSVideoAttachmentAdapter.m | 60 ++++ .../FullImageViewController.m | 60 ---- .../view controllers/MessagesViewController.m | 47 ++- .../TSMessageAdapters/TSMessageAdapterTest.m | 276 ++++++++++++++++++ .../translations/en.lproj/Localizable.strings | Bin 30990 -> 31200 bytes 15 files changed, 545 insertions(+), 101 deletions(-) create mode 100644 Signal/src/Models/TSMessageAdapaters/OWSMessageEditing.h create mode 100644 Signal/test/view controllers/Signals/TSMessageAdapters/TSMessageAdapterTest.m diff --git a/BUILDING.md b/BUILDING.md index 86d8e3a69..8342d2f7c 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 0e42bef81..0292272a5 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/src/Models/TSMessageAdapaters/OWSMessageEditing.h b/Signal/src/Models/TSMessageAdapaters/OWSMessageEditing.h new file mode 100644 index 000000000..ab10bc00c --- /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 1cd5fba7c..3eb21f78b 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 8faad7335..a7976d3ba 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 bf660d4d3..663d92b4f 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 c5f0fe389..d97c9a089 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 "OWSCall.h" #import "TSAttachmentPointer.h" +#import "TSAttachmentStream.h" #import "TSCall.h" -#import "OWSCall.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 533a083cf..f59525eaa 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 fb436b0f0..5df6e5106 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 40e2deed4..51ed816d6 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 e3c54d7a5..69f554129 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 0bf9c2c8e..6216ec229 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 3a39928d2..7072a89d5 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 000000000..2ed5a8210 --- /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 f4843c94ec1f3fe3366358fe681a17e630f7f2e7..7cd09afeffa9fb5ae1f65787d09bbb61a01d753b 100644 GIT binary patch delta 188 zcmeDC#Q5Md;|3SgdVK~h1_g#-h75*$h9ZU%AUlsCks+5M705~hizxuvDGZrlIgofB zLn%-W#7_mPQvlNWK(ZL9s|>6w8OYBA%Yw*apj|&6f$+o%diV8qC r=reFJC^5J)xG;D!gfPT|NmmA6AS;-`aq>kS(aB||0-NnjFE|4Lhf5}; delta 14 VcmaFxnX&H^;|3Sg&0=P!oB=m728#dy