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
This commit is contained in:
Michael Kirk 2016-03-11 10:34:39 -08:00
parent 70197fb482
commit 9db3b0db27
15 changed files with 546 additions and 102 deletions

View File

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

View File

@ -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 = "<group>"; };
453CC0361D08E1A60040EBA3 /* sn */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sn; path = translations/sn.lproj/Localizable.strings; sourceTree = "<group>"; };
453D28AF1D32B87100D523F0 /* OWSErrorMessage.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSErrorMessage.h; sourceTree = "<group>"; };
453D28B01D32B87100D523F0 /* OWSErrorMessage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSErrorMessage.m; sourceTree = "<group>"; };
@ -516,6 +518,7 @@
45843D1D1D2236B30013E85A /* OWSContactsSearcher.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSContactsSearcher.h; sourceTree = "<group>"; };
45843D1E1D2236B30013E85A /* OWSContactsSearcher.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSContactsSearcher.m; sourceTree = "<group>"; };
45843D211D223BA10013E85A /* OWSContactsSearcherTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSContactsSearcherTest.m; sourceTree = "<group>"; };
459C3F0C1C9B3A1B003ACF51 /* TSMessageAdapterTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = TSMessageAdapterTest.m; path = "view controllers/Signals/TSMessageAdapters/TSMessageAdapterTest.m"; sourceTree = "<group>"; };
45C681B51D305A580050903A /* OWSCall.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSCall.h; sourceTree = "<group>"; };
45C681B61D305A580050903A /* OWSCall.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSCall.m; sourceTree = "<group>"; };
45C681B91D305C080050903A /* OWSCallCollectionViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSCallCollectionViewCell.h; sourceTree = "<group>"; };
@ -1135,6 +1138,14 @@
path = Models;
sourceTree = "<group>";
};
459C3F0E1C9B3A20003ACF51 /* TSMessageAdapters */ = {
isa = PBXGroup;
children = (
459C3F0C1C9B3A1B003ACF51 /* TSMessageAdapterTest.m */,
);
name = TSMessageAdapters;
sourceTree = "<group>";
};
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",

View File

@ -0,0 +1,8 @@
// Copyright © 2016 Open Whisper Systems. All rights reserved.
@protocol OWSMessageEditing <NSObject>
- (BOOL)canPerformEditingAction:(SEL)action;
- (void)performEditingAction:(SEL)action;
@end

View File

@ -6,11 +6,12 @@
// Copyright (c) 2015 Open Whisper Systems. All rights reserved.
//
#import <Foundation/Foundation.h>
#import "OWSMessageEditing.h"
#import <JSQMessagesViewController/JSQPhotoMediaItem.h>
#import "TSAttachmentStream.h"
@interface TSAnimatedAdapter : JSQMediaItem
@class TSAttachmentStream;
@interface TSAnimatedAdapter : JSQMediaItem <OWSMessageEditing>
- (instancetype)initWithAttachment:(TSAttachmentStream *)attachment;
@ -19,5 +20,6 @@
- (BOOL)isVideo;
@property NSString *attachmentId;
@property NSData *fileData;
@end

View File

@ -8,13 +8,15 @@
#import "TSAnimatedAdapter.h"
#import "FLAnimatedImage.h"
#import "TSAttachmentStream.h"
#import "UIDevice+TSHardwareVersion.h"
#import <AssetsLibrary/AssetsLibrary.h>
#import <JSQMessagesViewController/JSQMessagesMediaViewBubbleImageMasker.h>
#import <MobileCoreServices/MobileCoreServices.h>
@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 {

View File

@ -6,11 +6,11 @@
// Copyright (c) 2014 Open Whisper Systems. All rights reserved.
//
#import <Foundation/Foundation.h>
#import "OWSMessageEditing.h"
#import <JSQMessagesViewController/JSQMessageData.h>
#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 <JSQMessageData>
@interface TSMessageAdapter : NSObject <JSQMessageData, OWSMessageEditing>
+ (id<JSQMessageData>)messageViewDataWithInteraction:(TSInteraction *)interaction inThread:(TSThread *)thread;
@property TSInteraction *interaction;
@property TSMessageAdapterType messageType;
@end

View File

@ -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 <MobileCoreServices/MobileCoreServices.h>
@interface TSMessageAdapter ()
@ -41,7 +43,7 @@
// for MediaMessages
@property JSQMediaItem *mediaItem;
@property JSQMediaItem<OWSMessageEditing> *mediaItem;
// ---
@ -57,6 +59,7 @@
+ (id<JSQMessageData>)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;
}

View File

@ -1,17 +1,20 @@
// Created by Frederic Jacobs on 17/12/14.
// Copyright (c) 2014 Open Whisper Systems. All rights reserved.
#import <Foundation/Foundation.h>
#import "OWSMessageEditing.h"
#import <JSQMessagesViewController/JSQPhotoMediaItem.h>
#import "TSAttachmentStream.h"
@interface TSPhotoAdapter : JSQPhotoMediaItem
@class TSAttachmentStream;
@interface TSPhotoAdapter : JSQPhotoMediaItem <OWSMessageEditing>
- (instancetype)initWithAttachment:(TSAttachmentStream *)attachment;
- (BOOL)isImage;
- (BOOL)isAudio;
- (BOOL)isVideo;
@property TSAttachmentStream *attachment;
@property NSString *attachmentId;
@end

View File

@ -2,6 +2,7 @@
// Copyright (c) 2014 Open Whisper Systems. All rights reserved.
#import "TSPhotoAdapter.h"
#import "TSAttachmentStream.h"
#import "UIDevice+TSHardwareVersion.h"
#import <JSQMessagesViewController/JSQMessagesMediaViewBubbleImageMasker.h>
@ -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 {

View File

@ -1,11 +1,12 @@
// Created by Frederic Jacobs on 17/12/14.
// Copyright (c) 2014 Open Whisper Systems. All rights reserved.
#import <Foundation/Foundation.h>
#import "OWSMessageEditing.h"
#import <JSQMessagesViewController/JSQVideoMediaItem.h>
#import "TSAttachmentStream.h"
@interface TSVideoAttachmentAdapter : JSQVideoMediaItem
@class TSAttachmentStream;
@interface TSVideoAttachmentAdapter : JSQVideoMediaItem <OWSMessageEditing>
@property NSString *attachmentId;
@property (nonatomic, strong) NSString *contentType;

View File

@ -3,10 +3,12 @@
#import "TSVideoAttachmentAdapter.h"
#import "MIMETypeUtil.h"
#import "TSAttachmentStream.h"
#import "TSMessagesManager.h"
#import "TSStorageManager+keyingMaterial.h"
#import <FFCircularProgressView.h>
#import <JSQMessagesViewController/JSQMessagesMediaViewBubbleImageMasker.h>
#import <MobileCoreServices/MobileCoreServices.h>
#import <SCWaveformView.h>
#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

View File

@ -7,7 +7,6 @@
// Copyright (c) 2014 Open Whisper Systems. All rights reserved.
//
#import <AssetsLibrary/AssetsLibrary.h>
#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

View File

@ -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<JSQMessageData>)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 {

View File

@ -0,0 +1,276 @@
// Copyright © 2016 Open Whisper Systems. All rights reserved.
#import "TSAttachmentStream.h"
#import "TSContentAdapters.h"
#import "TSInteraction.h"
#import <MobileCoreServices/MobileCoreServices.h>
#import <XCTest/XCTest.h>
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