TwitterImagePipelineTests/TIPProblematicImagesTest.m (373 lines of code) (raw):

// // TIPProblematicImagesTest.m // TwitterImagePipeline // // Created on 6/1/16. // Copyright © 2020 Twitter. All rights reserved. // #import "TIP_Project.h" #import "TIPError.h" #import "TIPGlobalConfiguration.h" #import "TIPImageFetchDownloadInternal.h" #import "TIPImageFetchMetrics.h" #import "TIPImageFetchOperation+Project.h" #import "TIPImageFetchRequest.h" #import "TIPImagePipeline+Project.h" #import "TIPPartialImage.h" #import "TIPTests.h" #import "UIImage+TIPAdditions.h" @import CoreImage; @import ImageIO; @import MobileCoreServices; @import XCTest; NS_INLINE NSData * __nullable UIImagePNGRepresentationUndeprecated(UIImage * __nonnull image) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" return UIImagePNGRepresentation(image); #pragma clang diagnostic pop } @interface TIPImagePipelineTestSuccessWithErrorDownloadProvider : NSObject <TIPImageFetchDownloadProvider> @property (nonatomic) NSData *downloadData; @property (nonatomic) NSError *downloadError; @end @interface TIPImagePipelineTestProblemHandler : NSObject <TIPProblemObserver> @property (nonatomic) XCTestExpectation *expectation; @property (nonatomic, copy) NSString *problemToExpect; @property (nonatomic, copy) NSDictionary *problemUserInfoSeen; @end @interface TIPImagePipelineTestFetchRequest : NSObject <TIPImageFetchRequest> @property (nonatomic) NSURL *imageURL; @property (nonatomic, copy) NSString *cannedImageFilePath; @end @interface TIPProblematicImagesTest : XCTestCase @end // https://o.twimg.com/2/proxy.jpg?t=HBhcaHR0cHM6Ly9jZG4uc2hvcGlmeS5jb20vcy9maWxlcy8xLzExNDkvNDc4MC9wcm9kdWN0cy8yNTE1XzEwMjR4MTAyNC5qcGc_MTIwOTg2OTg3MzMxMzE4ODM2MTgUwAcUxAgAFgASAA&s=KsBAcdWnqBlhzTqfi80_rZ4Yek7YubqUu0MLIBeuZpE #define kPINK_OUTFIT_JPEG_IMAGE_NAME @"pink_outfit" #define kPINK_OUTFIT_DIMENSIONS CGSizeMake(480, 546) #define kPINK_OUTFIT_TARGET_DIMENSIONS CGSizeMake(135, 153) #define kPINK_OUTFIT_TARGET_CONTENT_MODE UIViewContentModeScaleAspectFit #define kPINK_OUTFIT_EXPECTED_SCALED_DIMENSIONS CGSizeMake(135, 153) typedef struct { CGSize originalSize; CGSize targetSize; CGSize expectedScaledFitSizes[4]; CGSize expectedScaledFillSizes[4]; } TIPImageScalingTestCase; static UIImage *sPinkOutfitOriginalImage = nil; static NSData *sProblematicAvatarData = nil; static NSString *sProblematicAvatarPath = nil; @implementation TIPProblematicImagesTest + (void)setUp { NSBundle *thisBundle = TIPTestsResourceBundle(); NSString *pinkImagePath = [thisBundle pathForResource:kPINK_OUTFIT_JPEG_IMAGE_NAME ofType:@"jpg"]; sPinkOutfitOriginalImage = [UIImage imageWithContentsOfFile:pinkImagePath]; sProblematicAvatarPath = [thisBundle pathForResource:@"logo_only_final_reasonably_small" ofType:@"jpg"]; sProblematicAvatarData = [NSData dataWithContentsOfFile:sProblematicAvatarPath]; TIPGlobalConfiguration *globalConfig = [TIPGlobalConfiguration sharedInstance]; globalConfig.imageFetchDownloadProvider = [[TIPTestsImageFetchDownloadProviderOverrideClass() alloc] init]; } + (void)tearDown { sPinkOutfitOriginalImage = nil; sProblematicAvatarData = nil; sProblematicAvatarPath = nil; [TIPGlobalConfiguration sharedInstance].imageFetchDownloadProvider = nil; } - (void)testSizingImage { UIImage *scaledImage = [sPinkOutfitOriginalImage tip_scaledImageWithTargetDimensions:kPINK_OUTFIT_TARGET_DIMENSIONS contentMode:kPINK_OUTFIT_TARGET_CONTENT_MODE]; CGSize computedDimensions = TIPDimensionsScaledToTargetSizing(kPINK_OUTFIT_DIMENSIONS, kPINK_OUTFIT_TARGET_DIMENSIONS, kPINK_OUTFIT_TARGET_CONTENT_MODE); CGSize endDimensions = TIPDimensionsFromSizeScaled(scaledImage.size, scaledImage.scale); XCTAssertTrue(CGSizeEqualToSize(kPINK_OUTFIT_EXPECTED_SCALED_DIMENSIONS, endDimensions), @"%@ != %@", NSStringFromCGSize(kPINK_OUTFIT_EXPECTED_SCALED_DIMENSIONS), NSStringFromCGSize(endDimensions)); XCTAssertTrue(CGSizeEqualToSize(kPINK_OUTFIT_EXPECTED_SCALED_DIMENSIONS, computedDimensions), @"%@ != %@", NSStringFromCGSize(kPINK_OUTFIT_EXPECTED_SCALED_DIMENSIONS), NSStringFromCGSize(computedDimensions)); } - (void)testSizingDimensions { const CGFloat scales[] = { 0.5, 1.0, 2.0, 3.0 }; const UIViewContentMode contentModes[] = { UIViewContentModeScaleAspectFit, UIViewContentModeScaleAspectFill }; TIPImageScalingTestCase cases[] = { { .originalSize = CGSizeMake(480, 546), .targetSize = CGSizeMake(135, 153), .expectedScaledFitSizes = { CGSizeMake(136, 154), // 0.5 CGSizeMake(135, 153), // 1.0 CGSizeMake(134.5, 153), // 2.0 CGSizeMake(134.66666f, 153), // 3.0 }, .expectedScaledFillSizes = { CGSizeMake(136, 154), // 0.5 CGSizeMake(135, 154), // 1.0 CGSizeMake(135, 153.5f), // 2.0 CGSizeMake(135, 153.6666f), // 3.0 }, }, { .originalSize = CGSizeMake(135, 153), .targetSize = CGSizeMake(480, 546), .expectedScaledFitSizes = { CGSizeMake(480, 544), // 0.5 CGSizeMake(480, 544), // 1.0 CGSizeMake(480, 544), // 2.0 CGSizeMake(480, 544), // 3.0 }, .expectedScaledFillSizes = { CGSizeMake(482, 546), // 0.5 CGSizeMake(482, 546), // 1.0 CGSizeMake(482, 546), // 2.0 CGSizeMake(481.66666f, 546), // 3.0 }, }, { .originalSize = CGSizeMake(135, 154), .targetSize = CGSizeMake(480, 546), .expectedScaledFitSizes = { CGSizeMake(480, 544), // 0.5 CGSizeMake(479, 546), // 1.0 CGSizeMake(478.5, 546), // 2.0 CGSizeMake(478.66666f, 546), // 3.0 }, .expectedScaledFillSizes = { CGSizeMake(482, 546), // 0.5 CGSizeMake(480, 548), // 1.0 CGSizeMake(480, 547.5f), // 2.0 CGSizeMake(480, 547.6666f), // 3.0 }, }, }; for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { TIPImageScalingTestCase testCase = cases[i]; for (size_t scaleI = 0; scaleI < 4; scaleI++) { CGFloat scale = scales[scaleI]; for (size_t contentModeI = 0; contentModeI < 2; contentModeI++) { UIViewContentMode contentMode = contentModes[contentModeI]; CGSize expectedScaledSize = (UIViewContentModeScaleAspectFit == contentMode) ? testCase.expectedScaledFitSizes[scaleI] : testCase.expectedScaledFillSizes[scaleI]; CGSize scaledSize = TIPSizeScaledToTargetSizing(testCase.originalSize, testCase.targetSize, contentMode, scale); XCTAssertEqualWithAccuracy(scaledSize.width, expectedScaledSize.width, 0.001, @"%@ != %@ (for scale %f)", NSStringFromCGSize(scaledSize), NSStringFromCGSize(expectedScaledSize), scale); XCTAssertEqualWithAccuracy(scaledSize.height, expectedScaledSize.height, 0.001, @"%@ != %@ (for scale %f)", NSStringFromCGSize(scaledSize), NSStringFromCGSize(expectedScaledSize), scale); } } } } - (void)testProblematicAvatarThrows { if (sizeof(NSInteger) == sizeof(int32_t)) { // 32-bit devices yield an abort instead of an exception :( return; } NSData *imageData = sProblematicAvatarData; NSDictionary *options = @{ (NSString *)kCGImageSourceShouldCache : @NO }; CGImageSourceRef imageSourceRef = CGImageSourceCreateIncremental((__bridge CFDictionaryRef)options); CGImageSourceUpdateData(imageSourceRef, (__bridge CFDataRef)imageData, NO); CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(imageSourceRef, 0, NULL); XCTAssertTrue(properties != NULL); if (properties) { XCTAssertGreaterThan(CFDictionaryGetCount(properties), (CFIndex)0); CFRelease(properties); } CFRelease(imageSourceRef); } - (void)testProblematicAvatarHandledByTIP { /** This avatar caused big issues on iOS 9 and below. Now that TIP is iOS 10+, it should no longer be an issue, but we will keep the unit test to ensure we don't regress. */ if (sizeof(NSInteger) == sizeof(int32_t)) { // 32-bit devices yield an abort instead of an exception :( return; } UIImage *image2 = nil; UIImage *image1 = [UIImage imageWithData:sProblematicAvatarData]; TIPPartialImage *partialImage = [[TIPPartialImage alloc] initWithExpectedContentLength:sProblematicAvatarData.length]; [partialImage appendData:sProblematicAvatarData final:NO]; image2 = [[partialImage renderImageWithMode:TIPImageDecoderRenderModeFullFrameProgress targetDimensions:CGSizeZero targetContentMode:UIViewContentModeCenter decoded:YES] image]; XCTAssertNotNil(image2); // image can render [partialImage appendData:nil final:YES]; image2 = [[partialImage renderImageWithMode:TIPImageDecoderRenderModeFullFrameProgress targetDimensions:CGSizeZero targetContentMode:UIViewContentModeCenter decoded:YES] image]; XCTAssertNotNil(image2); XCTAssertTrue(CGSizeEqualToSize([image1 tip_dimensions], [image2 tip_dimensions])); NSData *pngData1 = UIImagePNGRepresentationUndeprecated(image1); NSData *pngData2 = UIImagePNGRepresentationUndeprecated(image2); // (╯°□°)╯︵ ┻━┻ // These images will serialize to different bytes (wasn't an issue prior to iOS 10)... // ...specifically an image with scale will have DPI info and PixelsPerMeter info. // Recreating an imageWithData: image with a scale will get them to be consistent. if (image1.scale != image2.scale) { UIImage *roundTrip1 = [UIImage imageWithData:pngData1]; UIImage *roundTrip2 = [UIImage imageWithData:pngData2]; XCTAssertEqual(roundTrip1.scale, roundTrip2.scale); XCTAssertTrue(CGSizeEqualToSize(roundTrip1.size, roundTrip2.size)); image1 = [UIImage imageWithData:sProblematicAvatarData scale:image2.scale]; pngData1 = UIImagePNGRepresentationUndeprecated(image1); UIImage *imageTest = [UIImage imageWithData:pngData1 scale:image2.scale]; XCTAssertTrue(CGSizeEqualToSize(imageTest.size, image2.size)); } XCTAssertEqualObjects(pngData1, pngData2); } - (void)testProblematicAvatarFetch { if (sizeof(NSInteger) == sizeof(int32_t)) { // 32-bit devices yield an abort instead of an exception :( return; } // Prior to iOS 10 (when this issue was fixed) it was possible // to have a malformed image trigger an exception (32-bit unit // tests won't catch the exception). // // TIP adds a protection around ImageIO code (in the codecs) to // avoid crashing and salvage the decoding. id<TIPImageFetchDownloadProviderWithStubbingSupport> provider = (id<TIPImageFetchDownloadProviderWithStubbingSupport>)[TIPGlobalConfiguration sharedInstance].imageFetchDownloadProvider; XCTAssertNotNil(sProblematicAvatarPath); XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath:sProblematicAvatarPath], @"%@", sProblematicAvatarPath); TIPImagePipelineTestFetchRequest *request = [[TIPImagePipelineTestFetchRequest alloc] init]; request.imageURL = [NSURL URLWithString:@"https://pbs.twimg.com/profile_images/1205320369/logo_only_final_reasonably_small.jpg"]; request.cannedImageFilePath = sProblematicAvatarPath; NSData *cannedImageData = [NSData dataWithContentsOfFile:request.cannedImageFilePath options:NSDataReadingMappedIfSafe error:NULL]; __block NSError *error; __block TIPImageContainer *container; TIPImagePipeline *pipeline = [[TIPImagePipeline alloc] initWithIdentifier:@"problem.avatar"]; TIPImageFetchOperation *op = nil; TIPImageFetchMetricInfo *metricInfo = nil; [pipeline clearDiskCache]; [pipeline clearMemoryCaches]; // Slow load may or may not encounter an exception // Either way, the image should be loaded on completion [provider addDownloadStubForRequestURL:request.imageURL responseData:cannedImageData responseMIMEType:@"image/jpeg" shouldSupportResuming:YES suggestedBitrate:512 * 1000]; op = [pipeline operationWithRequest:request context:nil completion:^(id<TIPImageFetchResult> result, NSError *theError) { container = result.imageContainer; error = theError; }]; [pipeline fetchImageWithOperation:op]; [op waitUntilFinishedWithoutBlockingRunLoop]; [provider removeDownloadStubForRequestURL:request.imageURL]; XCTAssertNil(error); XCTAssertNotNil(container.image); metricInfo = [op.metrics metricInfoForSource:TIPImageLoadSourceNetwork]; XCTAssertNotNil(metricInfo); XCTAssertNil(error, @"%@\n%@", metricInfo.networkRequest, metricInfo.networkRequest.allHTTPHeaderFields); [pipeline clearDiskCache]; [pipeline clearMemoryCaches]; // Fast load DOES encounter an issue // But will be handled and still load [provider addDownloadStubForRequestURL:request.imageURL responseData:cannedImageData responseMIMEType:@"image/jpeg" shouldSupportResuming:YES suggestedBitrate:0]; op = [pipeline operationWithRequest:request context:nil completion:^(id<TIPImageFetchResult> result, NSError *theError) { container = result.imageContainer; error = theError; }]; [pipeline fetchImageWithOperation:op]; [op waitUntilFinishedWithoutBlockingRunLoop]; [provider removeDownloadStubForRequestURL:request.imageURL]; XCTAssertNil(error); XCTAssertNotNil(container.image); metricInfo = [op.metrics metricInfoForSource:TIPImageLoadSourceNetwork]; XCTAssertNotNil(metricInfo); XCTAssertNil(error, @"%@\n%@", metricInfo.networkRequest, metricInfo.networkRequest.allHTTPHeaderFields); [pipeline clearDiskCache]; [pipeline clearMemoryCaches]; } - (void)testCompletedDownloadWithError { /** If the server yields an error despite all the data loading, we want TIP to be robust at catching these problems, reporting them, and continuing without a failure. */ NSBundle *thisBundle = TIPTestsResourceBundle(); NSString *imagePath = [thisBundle pathForResource:@"twitterfied" ofType:@"jpg"]; NSData *imageData = [NSData dataWithContentsOfFile:imagePath]; TIPImagePipelineTestSuccessWithErrorDownloadProvider *provider = [[TIPImagePipelineTestSuccessWithErrorDownloadProvider alloc] init]; provider.downloadData = imageData; provider.downloadError = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorTimedOut userInfo:nil]; TIPImagePipelineTestProblemHandler *problemObserver = [[TIPImagePipelineTestProblemHandler alloc] init]; problemObserver.problemToExpect = TIPProblemImageDownloadedWithUnnecessaryError; id<TIPImageFetchDownloadProvider> originalProvider = [TIPGlobalConfiguration sharedInstance].imageFetchDownloadProvider; [TIPGlobalConfiguration sharedInstance].imageFetchDownloadProvider = provider; id<TIPProblemObserver> originalProblemObserver = [TIPGlobalConfiguration sharedInstance].problemObserver; [TIPGlobalConfiguration sharedInstance].problemObserver = problemObserver; tip_defer(^{ [TIPGlobalConfiguration sharedInstance].imageFetchDownloadProvider = originalProvider; [TIPGlobalConfiguration sharedInstance].problemObserver = originalProblemObserver; }); XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath:imagePath]); TIPImagePipelineTestFetchRequest *request = [[TIPImagePipelineTestFetchRequest alloc] init]; request.imageURL = [NSURL URLWithString:@"https://dummy.twitter.com/some_path/image.jpg"]; request.cannedImageFilePath = imagePath; __block NSError *error; __block id<TIPImageFetchResult> result; TIPImagePipeline *pipeline = [[TIPImagePipeline alloc] initWithIdentifier:@"error.image.complete"]; TIPImageFetchOperation *op = nil; [pipeline clearDiskCache]; [pipeline clearMemoryCaches]; problemObserver.expectation = [self expectationWithDescription:@"Problem.Expectation"]; op = [pipeline operationWithRequest:request context:nil completion:^(id<TIPImageFetchResult> theResult, NSError *theError) { result = theResult; error = theError; }]; [pipeline fetchImageWithOperation:op]; [self waitForExpectations:@[problemObserver.expectation] timeout:10.0]; [op waitUntilFinishedWithoutBlockingRunLoop]; XCTAssertNil(error); XCTAssertNotNil(result.imageContainer.image); XCTAssertEqual(result.imageSource, TIPImageLoadSourceNetwork); [pipeline clearMemoryCaches]; op = [pipeline operationWithRequest:request context:nil completion:^(id<TIPImageFetchResult> theResult, NSError *theError) { result = theResult; error = theError; }]; [pipeline fetchImageWithOperation:op]; [op waitUntilFinishedWithoutBlockingRunLoop]; XCTAssertNil(error); XCTAssertNotNil(result.imageContainer.image); XCTAssertEqual(result.imageSource, TIPImageLoadSourceDiskCache); [pipeline clearDiskCache]; [pipeline clearMemoryCaches]; } @end @interface TIPImagePipelineTestSuccessWithErrorDownload : NSObject <TIPImageFetchDownload> @property (nonatomic, readonly) NSData *downloadData; @property (nonatomic, readonly) NSError *downloadError; - (instancetype)initWithContext:(id<TIPImageFetchDownloadContext>)context downloadData:(NSData *)data downloadError:(NSError *)error; @end @implementation TIPImagePipelineTestSuccessWithErrorDownload @synthesize context = _context; @synthesize finalURLRequest = _finalURLRequest; - (instancetype)initWithContext:(id<TIPImageFetchDownloadContext>)context downloadData:(NSData *)data downloadError:(NSError *)error { if (self = [super init]) { _context = context; _downloadData = data; _downloadError = error; } return self; } - (void)start { dispatch_async(self.context.downloadQueue, ^{ [self.context.client imageFetchDownloadDidStart:self]; [self.context.client imageFetchDownload:self hydrateRequest:self.context.originalRequest completion:^(NSError * _Nullable hError) { if (hError) { [self.context.client imageFetchDownload:self didCompleteWithError:hError]; return; } [self.context.client imageFetchDownload:self authorizeRequest:self.context.hydratedRequest completion:^(NSError * _Nullable aError) { if (aError) { [self.context.client imageFetchDownload:self didCompleteWithError:aError]; return; } NSMutableDictionary *headers = [[NSMutableDictionary alloc] init]; headers[@"Content-Length"] = [@(self.downloadData.length) stringValue]; headers[@"Content-Type"] = @"image/jpeg"; headers[@"Accept-Ranges"] = @"bytes"; headers[@"Last-Modified"] = @"Wed, 15 Nov 1995 04:58:08 GMT"; NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:self.context.hydratedRequest.URL statusCode:200 HTTPVersion:@"http/1.1" headerFields:headers]; [self.context.client imageFetchDownload:self didReceiveURLResponse:response]; dispatch_async(self.context.downloadQueue, ^{ [self.context.client imageFetchDownload:self didReceiveData:self.downloadData]; dispatch_async(self.context.downloadQueue, ^{ [self.context.client imageFetchDownload:self didCompleteWithError:self.downloadError]; }); }); }]; }]; }); } - (void)cancelWithDescription:(NSString *)cancelDescription { // noop } - (void)discardContext { _context = nil; } @end @implementation TIPImagePipelineTestSuccessWithErrorDownloadProvider - (id<TIPImageFetchDownload>)imageFetchDownloadWithContext:(id<TIPImageFetchDownloadContext>)context { return [[TIPImagePipelineTestSuccessWithErrorDownload alloc] initWithContext:context downloadData:self.downloadData downloadError:self.downloadError]; } @end @implementation TIPImagePipelineTestProblemHandler - (void)tip_problemWasEncountered:(NSString *)problemName userInfo:(NSDictionary<NSString *, id> *)userInfo { if ([self.problemToExpect isEqualToString:problemName]) { self.problemUserInfoSeen = userInfo; if (self.expectation) { [self.expectation fulfill]; } } } @end