diff --git a/Example/SnapchatKit-OSX/SnapchatKit-OSX/main.m b/Example/SnapchatKit-OSX/SnapchatKit-OSX/main.m index 7039861..b9bff92 100644 --- a/Example/SnapchatKit-OSX/SnapchatKit-OSX/main.m +++ b/Example/SnapchatKit-OSX/SnapchatKit-OSX/main.m @@ -223,8 +223,10 @@ int main(int argc, const char * argv[]) { [SKClient sharedClient].casperUserAgent = kCasperUserAgent; #endif -// [[SKClient sharedClient] signInWithUsername:kUsername password:kPassword completion:^(NSDictionary *dict, NSError *error) { - [[SKClient sharedClient] restoreSessionWithUsername:kUsername snapchatAuthToken:kAuthToken doGetUpdates:^(NSError *error) { + SKBlob *blob = [SKBlob blobWithContentsOfPath:@"/Users/tantan/Desktop/upload"]; + + [[SKClient sharedClient] signInWithUsername:kUsername password:kPassword completion:^(NSDictionary *dict, NSError *error) { +// [[SKClient sharedClient] restoreSessionWithUsername:kUsername snapchatAuthToken:kAuthToken doGetUpdates:^(NSError *error) { if (!error) { SKSession *session = [SKClient sharedClient].currentSession; [[session valueForKey:@"_JSON"] writeToFile:[directory stringByAppendingPathComponent:@"current-session.plist"] atomically:YES]; @@ -260,6 +262,10 @@ int main(int argc, const char * argv[]) { // } // }]; + [[SKClient sharedClient] postStory:blob for:0 completion:^(NSError *error) { + NSLog(@"%@", error); + }]; + // Get unread snaps NSArray *unread = session.unread; diff --git a/Pod/Classes/Model/SKBlob.h b/Pod/Classes/Model/SKBlob.h index 141e07c..bb6bd33 100644 --- a/Pod/Classes/Model/SKBlob.h +++ b/Pod/Classes/Model/SKBlob.h @@ -11,6 +11,8 @@ @class SKStory; +extern NSData * SKThumbnailFromGCImage(CGImageRef image); + /** A wrapper for the various kinds of data used throughout the API. */ @interface SKBlob : NSObject @@ -46,10 +48,15 @@ @property (nonatomic, readonly) NSData *data; /** The overlay for the video. \c nil if not applicable. */ @property (nonatomic, readonly) NSData *overlay; +/** Lazily initialized. The compressed data for the snap should it be uploaded. nil if not need be compressed. */ +@property (nonatomic, readonly) NSData *zipData; +/** Lazily initialized. + @discussion The thumbnail for the video to be uploaded. nil if not applicable. + @note You may assign your own if you wish, and it will be used instead of the default one. */ +@property (nonatomic ) NSData *videoThumbnail; /** \c YES if the data is for a JPEG, \c NO if it's something other than a JPEG or PNG. */ @property (nonatomic, readonly) BOOL isImage; /** \c YES if the data is for a MPEG4 video, \c NO if it's something else. */ @property (nonatomic, readonly) BOOL isVideo; - @end diff --git a/Pod/Classes/Model/SKBlob.m b/Pod/Classes/Model/SKBlob.m index 5bd8b48..04b9ebb 100644 --- a/Pod/Classes/Model/SKBlob.m +++ b/Pod/Classes/Model/SKBlob.m @@ -9,13 +9,15 @@ #import "SKBlob.h" #import "SnapchatKit-Constants.h" #import "NSData+SnapchatKit.h" +#import "NSString+SnapchatKit.h" #import "SKStory.h" - #import "SKRequest.h" #import "SSZipArchive.h" +@import AVFoundation; @implementation SKBlob +@synthesize zipData = _zipData; + (instancetype)blobWithContentsOfPath:(NSString *)path { return [[self alloc] initWithContentsOfPath:path]; @@ -129,10 +131,10 @@ - (id)initWithContentsOfPath:(NSString *)path { } // Delete file(s) -// NSError *error = nil; -// [[NSFileManager defaultManager] removeItemAtPath:path error:&error]; -// if (error && kVerboseLog) -// SKLog(@"Error deleting blob: %@", error); + // NSError *error = nil; + // [[NSFileManager defaultManager] removeItemAtPath:path error:&error]; + // if (error && kVerboseLog) + // SKLog(@"Error deleting blob: %@", error); } else { return nil; } @@ -169,8 +171,8 @@ - (NSArray *)writeToPath:(NSString *)path filename:(NSString *)filename atomical return @[path]; } else { path = [path stringByAppendingPathComponent:filename]; - NSString *overlay = [path stringByAppendingPathComponent:[filename stringByAppendingString:[@"-overlay" stringByAppendingString:self.overlay.appropriateFileExtension]]]; - NSString *video = [path stringByAppendingPathComponent:[filename stringByAppendingString:self.data.appropriateFileExtension]]; + NSString *overlay = [path stringByAppendingPathComponent:[filename stringByAppendingString:[@"-overlay" stringByAppendingString:_overlay.appropriateFileExtension]]]; + NSString *video = [path stringByAppendingPathComponent:[filename stringByAppendingString:_data.appropriateFileExtension]]; [[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil]; [self.data writeToFile:video atomically:atomically]; [self.overlay writeToFile:overlay atomically:atomically]; @@ -202,4 +204,115 @@ - (void)decompress:(ResponseBlock)completion { } } +- (NSData *)zipData { + if (_zipData) return _zipData; + + if (self.overlay) { + NSString *tmpDir = SKUniqueIdentifier(); + NSString *videoName = [@"media~zip-" stringByAppendingString:SKUniqueIdentifier().capitalizedString]; + NSString *overlayName = [@"overlay~zip-" stringByAppendingString:SKUniqueIdentifier().capitalizedString]; + NSString *folder = [SKTempDirectory() stringByAppendingPathComponent:tmpDir]; + NSString *archive = [SKTempDirectory() stringByAppendingPathComponent:[tmpDir stringByAppendingString:@".zip"]]; + NSString *video = [folder stringByAppendingPathComponent:videoName]; + NSString *overlay = [folder stringByAppendingPathComponent:overlayName]; + + [[NSFileManager defaultManager] createDirectoryAtPath:folder withIntermediateDirectories:YES attributes:nil error:nil]; + [self.data writeToFile:video atomically:YES]; + [self.overlay writeToFile:overlay atomically:YES]; + BOOL success = [SSZipArchive createZipFileAtPath:archive withContentsOfDirectory:folder]; + + if (success) { + _zipData = [NSData dataWithContentsOfFile:archive]; + // zip failed + if (_zipData.length == 22) _zipData = nil; + } + + // Cleanup + [[NSFileManager defaultManager] removeItemAtPath:archive error:nil]; + [[NSFileManager defaultManager] removeItemAtPath:folder error:nil]; + } + + return _zipData; +} + +- (NSData *)videoThumbnail { + if (_videoThumbnail) return _videoThumbnail; + + if (self.isVideo) { + NSString *path = [SKTempDirectory() stringByAppendingPathComponent:@"tmp-video.mp4"]; + [self.data writeToFile:path atomically:YES]; + AVURLAsset *asset = [AVURLAsset assetWithURL:[NSURL fileURLWithPath:path]]; + + AVAssetImageGenerator *gen = [AVAssetImageGenerator assetImageGeneratorWithAsset:asset]; + + CGImageRef image = [gen copyCGImageAtTime:CMTimeMake(0, 600) actualTime:NULL error:NULL]; + if (image != NULL) { + _videoThumbnail = SKThumbnailFromGCImage(image); + CGImageRelease(image); + } + + // Cleanup + [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; + } + + return _videoThumbnail; +} + @end + + + +NSData * SKThumbnailFromGCImage(CGImageRef image) { + CGFloat width = CGImageGetWidth(image); + CGFloat height = CGImageGetHeight(image); + CGFloat s = MIN(MIN(width, height), 102); + + // Images too small to scale + if (s < 102) { + CGFloat cx = s/2.f - 51; + CGFloat cy = s/2.f - 51; + CGImageRef cropped = CGImageCreateWithImageInRect(image, CGRectMake(cx, cy, s, s)); + return CFBridgingRelease(CGDataProviderCopyData(CGImageGetDataProvider(cropped))); + } + + CGFloat scale = MIN(width, height) / 102.f; + NSInteger bitsPerComponent = CGImageGetBitsPerComponent(image); + NSInteger bytesPerRow = CGImageGetBytesPerRow(image); + CGColorSpaceRef colorSpace = CGImageGetColorSpace(image); + CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(image); + width /= scale; height /= scale; + CGFloat cx = (width - s) / 2.f; + CGFloat cy = (height - s) / 2.f; + + CGContextRef context = CGBitmapContextCreate(nil, width, height, bitsPerComponent, bytesPerRow, colorSpace, bitmapInfo); + + CGContextSetInterpolationQuality(context, kCGInterpolationHigh); + CGContextDrawImage(context, CGRectMake(0, 0, width, height), image); + CGImageRef scaled = CGBitmapContextCreateImage(context); + CGImageRef cropped = CGImageCreateWithImageInRect(scaled, CGRectMake(cx, cy, s, s)); + + // Cleanup + CGImageRelease(scaled); + NSData *ret; +#if __has_include() + ret = UIImagePNGRepresentation([UIImage imageWithCGImage:cropped]); +#elif __has_include() + ret = [[[NSBitmapImageRep alloc] initWithCGImage:cropped] representationUsingType:NSPNGFileType properties:@{}]; +#else +#warning Target platform is missing a way to convert a CGImage to NSData. +#endif + CGImageRelease(cropped); + + return ret; +} + + + + + + + + + + + diff --git a/Pod/Classes/Model/SKRequest.m b/Pod/Classes/Model/SKRequest.m index 0e714bc..48cad7c 100644 --- a/Pod/Classes/Model/SKRequest.m +++ b/Pod/Classes/Model/SKRequest.m @@ -152,13 +152,15 @@ - (id)initWithPOSTEndpoint:(NSString *)endpoint query:(NSDictionary *)params hea // Set HTTPBody // Only for uploading snaps here - if ([endpoint isEqualToString:SKEPSnaps.upload] || [endpoint isEqualToString:SKEPAccount.avatar.set]) { + if ([endpoint isEqualToString:SKEPSnaps.upload] || + [endpoint isEqualToString:SKEPAccount.avatar.set] || + [endpoint isEqualToString:SKEPStories.post]) { [self setValue:@"multipart/form-data; boundary=Boundary+0xAbCdEfGbOuNdArY" forHTTPHeaderField:SKHeaders.contentType]; NSMutableData *body = [NSMutableData data]; [body appendData:[[NSString stringWithFormat:@"\r\n--%@\r\n", SKConsts.boundary] dataUsingEncoding:NSUTF8StringEncoding]]; for (NSString *key in json.allKeys) { - if ([key isEqualToString:@"data"]) { + if ([key isEqualToString:@"data"] || [key isEqualToString:@"thumbnail_data"]) { [body appendData:[NSData boundaryWithKey:key forDataValue:json[key]]]; } else { [body appendData:[NSData boundaryWithKey:key forStringValue:(NSString *)json[key]]]; diff --git a/Pod/Classes/Model/SKStoryOptions.h b/Pod/Classes/Model/SKStoryOptions.h index 22e2cf4..1352f10 100644 --- a/Pod/Classes/Model/SKStoryOptions.h +++ b/Pod/Classes/Model/SKStoryOptions.h @@ -21,7 +21,7 @@ @property (nonatomic) NSString *text; /** Defaults to \c NO. */ @property (nonatomic) BOOL cameraFrontFacing; -/** Defaults to 3. */ +/** Defaults to 3. Ignored for videos. */ @property (nonatomic) NSTimeInterval timer; @end diff --git a/Pod/Classes/Networking/SKClient+Snaps.m b/Pod/Classes/Networking/SKClient+Snaps.m index 5a9f9cc..43a73a2 100644 --- a/Pod/Classes/Networking/SKClient+Snaps.m +++ b/Pod/Classes/Networking/SKClient+Snaps.m @@ -45,7 +45,7 @@ - (void)sendSnap:(SKBlob *)blob options:(SKSnapOptions *)options completion:(Res @"recipient_ids": options.recipients.recipientsString, @"reply": @(options.isReply), @"time": @((NSUInteger)options.timer), - @"zipped": @0, + @"zipped": blob.zipData ? @1 : @0, @"username": self.username}; [self postTo:SKEPSnaps.send query:query callback:^(NSDictionary *json, NSError *sendError) { if (!sendError) { @@ -65,8 +65,8 @@ - (void)uploadSnap:(SKBlob *)blob completion:(ResponseBlock)completion { NSDictionary *query = @{@"media_id": uuid, @"type": blob.isImage ? @(SKMediaKindImage) : @(SKMediaKindVideo), - @"data": blob.data, - @"zipped": @0, + @"data": blob.zipData ? blob.zipData : blob.data, + @"zipped": blob.zipData ? @1 : @0, @"features_map": @"{}", @"username": self.username}; diff --git a/Pod/Classes/Networking/SKClient+Stories.h b/Pod/Classes/Networking/SKClient+Stories.h index 5ac404e..29e83f0 100644 --- a/Pod/Classes/Networking/SKClient+Stories.h +++ b/Pod/Classes/Networking/SKClient+Stories.h @@ -22,7 +22,7 @@ - (void)postStory:(SKBlob *)blob options:(SKStoryOptions *)options completion:(ErrorBlock)completion; /** Posts a story with the given options. @param blob The \c SKBlob object containing the image or video data to send. Can be created with any \c NSData object. - @param duration The length of the story. + @param duration The length of the story. This value is ignored for video snaps. @param completion Takes an error, if any. @note Assumes camera not front facing. */ - (void)postStory:(SKBlob *)blob for:(NSTimeInterval)duration completion:(ErrorBlock)completion; diff --git a/Pod/Classes/Networking/SKClient+Stories.m b/Pod/Classes/Networking/SKClient+Stories.m index d06d638..a21a0f7 100644 --- a/Pod/Classes/Networking/SKClient+Stories.m +++ b/Pod/Classes/Networking/SKClient+Stories.m @@ -31,22 +31,30 @@ - (void)postStory:(SKBlob *)blob options:(SKStoryOptions *)options completion:(E [self uploadStory:blob completion:^(NSString *mediaID, NSError *error) { if (!error) { - NSDictionary *query = @{@"caption_text_display": options.text, - @"story_timestamp": [NSString timestamp], - @"type": blob.isImage ? @(SKMediaKindImage) : @(SKMediaKindVideo), - @"media_id": mediaID, - @"client_id": mediaID, - @"time": @((NSUInteger)options.timer), - @"username": self.username, - @"camera_front_facing": @(options.cameraFrontFacing), - @"my_story": @"true", - @"zipped": @0, - @"shared_ids": @"{}"}; + NSMutableDictionary *query = @{@"camera_front_facing": @(options.cameraFrontFacing), + @"client_id": mediaID, + @"filter_id": @"", + @"media_id": mediaID, + @"orientation": @"0", + @"story_timestamp": [NSString timestamp], + @"time": @((NSUInteger)options.timer), + @"type": blob.isImage ? @(SKMediaKindImage) : @(SKMediaKindVideo), + @"username": self.username, +// @"my_story": @"true", + @"zipped": blob.zipData ? @1 : @0}.mutableCopy; + // Optional parts + if (options.text) { + query[@"caption_text_display"] = options.text; + } + if (blob.videoThumbnail) { + query[@"thumbnail_data"] = blob.videoThumbnail; + } + [self postTo:SKEPStories.post query:query callback:^(NSDictionary *json, NSError *sendError) { - completion(sendError); + SKRunBlockP(completion, sendError); }]; } else { - completion(error); + SKRunBlockP(completion, error); } }]; } @@ -56,13 +64,13 @@ - (void)uploadStory:(SKBlob *)blob completion:(ResponseBlock)completion { NSDictionary *query = @{@"media_id": uuid, @"type": blob.isImage ? @(SKMediaKindImage) : @(SKMediaKindVideo), - @"data": blob.data, - @"zipped": @0, + @"data": blob.zipData ? blob.zipData : blob.data, + @"zipped": blob.zipData ? @1 : @0, @"features_map": @"{}", @"username": self.username}; [self postTo:SKEPStories.upload query:query callback:^(id object, NSError *error) { - completion(error ? nil : uuid, error); + SKRunBlockP(completion, error ? nil : uuid, error); }]; } diff --git a/Pod/Classes/SnapchatKit-Constants.h b/Pod/Classes/SnapchatKit-Constants.h index 0674ce3..b4c2bcc 100644 --- a/Pod/Classes/SnapchatKit-Constants.h +++ b/Pod/Classes/SnapchatKit-Constants.h @@ -26,6 +26,10 @@ #define SK_NAMESPACE(name, vals) extern const struct name vals name #define SK_NAMESPACE_IMP(name) const struct name name = +#define SKRunBlock(block) if ( block ) block() +#define SKRunBlockP(block, ...) if ( block ) block( __VA_ARGS__ ) + + typedef void (^RequestBlock)(NSData *data, NSURLResponse *response, NSError *error); typedef void (^BooleanBlock)(BOOL success, NSError *error); typedef void (^DataBlock)(NSData *data, NSError *error); diff --git a/Pod/Classes/SnapchatKit-Constants.m b/Pod/Classes/SnapchatKit-Constants.m index 2ce0c43..f19eb3f 100644 --- a/Pod/Classes/SnapchatKit-Constants.m +++ b/Pod/Classes/SnapchatKit-Constants.m @@ -260,8 +260,8 @@ BOOL SKMediaKindIsVideo(SKMediaKind mediaKind) { #pragma mark SKEPSnaps SK_NAMESPACE_IMP(SKEPSnaps) { - .loadBlob = @"/bq/blob", // /ph/blob ? - .upload = @"/ph/upload", + .loadBlob = @"/bq/blob", + .upload = @"/bq/upload", .send = @"/loq/retry", .retry = @"/loq/send" }; @@ -269,7 +269,7 @@ BOOL SKMediaKindIsVideo(SKMediaKind mediaKind) { #pragma mark SKEPStories SK_NAMESPACE_IMP(SKEPStories) { .stories = @"/bq/stories", - .upload = @"/ph/upload", + .upload = @"/bq/upload", .blob = @"/bq/story_blob?story_id=", .thumb = @"/bq/story_thumbnail?story_id=", .authBlob = @"/bq/auth_story_blob", diff --git a/SnapchatKit.podspec b/SnapchatKit.podspec index fb8f57b..3b10a91 100644 --- a/SnapchatKit.podspec +++ b/SnapchatKit.podspec @@ -1,20 +1,20 @@ Pod::Spec.new do |s| - s.name = "SnapchatKit" - s.version = "0.6.0" - s.summary = "An Objective-C implementation of the unofficial Snapchat API." - s.homepage = "https://github.com/ThePantsThief/SnapchatKit" - s.license = 'MIT' - s.author = { "ThePantsThief" => "tannerbennett@me.com" } - s.source = { :git => "https://github.com/ThePantsThief/SnapchatKit.git", :tag => s.version.to_s } - s.social_media_url = 'https://twitter.com/ThePantsThief' +s.name = "SnapchatKit" +s.version = "0.6.1" +s.summary = "An Objective-C implementation of the unofficial Snapchat API." +s.homepage = "https://github.com/ThePantsThief/SnapchatKit" +s.license = 'MIT' +s.author = { "ThePantsThief" => "tannerbennett@me.com" } +s.source = { :git => "https://github.com/ThePantsThief/SnapchatKit.git", :tag => s.version.to_s } +s.social_media_url = 'https://twitter.com/ThePantsThief' - s.requires_arc = true - s.ios.deployment_target = '7.0' - s.osx.deployment_target = '10.8' +s.requires_arc = true +s.ios.deployment_target = '7.0' +s.osx.deployment_target = '10.8' - s.source_files = 'Pod/Classes/*', 'Pod/Classes/**/*', 'Pod/Dependencies/*', 'Pod/Dependencies/**/*' - # s.dependency 'AFNetworking', '~> 2.5' - # s.dependency 'SSZipArchive' - s.dependency 'Mantle', '~> 2.0' - s.library = 'z' +s.source_files = 'Pod/Classes/*', 'Pod/Classes/**/*', 'Pod/Dependencies/*', 'Pod/Dependencies/**/*' +# s.dependency 'AFNetworking', '~> 2.5' +# s.dependency 'SSZipArchive' +s.dependency 'Mantle', '~> 2.0' +s.library = 'z' end