Merge pull request #3425 from dreampiggy/feature/imageio_force_decode_option_control

Added `SDImageCoderDecodeUseLazyDecoding` to control whether to use lazy-decoding for ImageIO. Introduce new workaround to strip CGImage retained CGImageSource on iOS 15
This commit is contained in:
DreamPiggy 2022-11-08 14:36:55 +08:00 committed by GitHub
commit f6769186b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 133 additions and 35 deletions

View File

@ -116,7 +116,11 @@ UIImage * _Nullable SDImageCacheDecodeImageData(NSData * _Nonnull imageData, NSS
}
if (image) {
BOOL shouldDecode = !SD_OPTIONS_CONTAINS(options, SDWebImageAvoidDecodeImage);
if ([image.class conformsToProtocol:@protocol(SDAnimatedImage)]) {
BOOL lazyDecode = [coderOptions[SDImageCoderDecodeUseLazyDecoding] boolValue];
if (lazyDecode) {
// lazyDecode = NO means we should not forceDecode, highest priority
shouldDecode = NO;
} else if ([image.class conformsToProtocol:@protocol(SDAnimatedImage)]) {
// `SDAnimatedImage` do not decode
shouldDecode = NO;
} else if (image.sd_isAnimated) {

View File

@ -61,6 +61,19 @@ FOUNDATION_EXPORT SDImageCoderOption _Nonnull const SDImageCoderDecodeFileExtens
*/
FOUNDATION_EXPORT SDImageCoderOption _Nonnull const SDImageCoderDecodeTypeIdentifierHint;
/**
A BOOL value indicating whether to use lazy-decoding. Defaults to NO on animated image coder, but defaults to YES on static image coder.
CGImageRef, this image object typically support lazy-decoding, via the `CGDataProviderCreateDirectAccess` or `CGDataProviderCreateSequential`
Which allows you to provide a lazy-called callback to access bitmap buffer, so that you can achieve lazy-decoding when consumer actually need bitmap buffer
UIKit on iOS use heavy on this and ImageIO codec prefers to lazy-decoding for common Hardware-Accelerate format like JPEG/PNG/HEIC
But however, the consumer may access bitmap buffer when running on main queue, like CoreAnimation layer render image. So this is a trade-off
You can force us to disable the lazy-decoding and always allocate bitmap buffer on RAM, but this may have higher ratio of OOM (out of memory)
@note The default value is NO for animated image coder (means `animatedImageFrameAtIndex:`)
@note The default value is YES for static image coder (means `decodedImageWithData:`)
@note works for `SDImageCoder`, `SDProgressiveImageCoder`, `SDAnimatedImageCoder`.
*/
FOUNDATION_EXPORT SDImageCoderOption _Nonnull const SDImageCoderDecodeUseLazyDecoding;
// These options are for image encoding
/**
A Boolean value indicating whether to encode the first frame only for animated image during encoding. (NSNumber). If not provide, encode animated image if need.

View File

@ -14,6 +14,7 @@ SDImageCoderOption const SDImageCoderDecodePreserveAspectRatio = @"decodePreserv
SDImageCoderOption const SDImageCoderDecodeThumbnailPixelSize = @"decodeThumbnailPixelSize";
SDImageCoderOption const SDImageCoderDecodeFileExtensionHint = @"decodeFileExtensionHint";
SDImageCoderOption const SDImageCoderDecodeTypeIdentifierHint = @"decodeTypeIdentifierHint";
SDImageCoderOption const SDImageCoderDecodeUseLazyDecoding = @"decodeUseLazyDecoding";
SDImageCoderOption const SDImageCoderEncodeFirstFrameOnly = @"encodeFirstFrameOnly";
SDImageCoderOption const SDImageCoderEncodeCompressionQuality = @"encodeCompressionQuality";

View File

@ -23,6 +23,30 @@ static NSString * kSDCGImageSourceRasterizationDPI = @"kCGImageSourceRasterizati
// Specify File Size for lossy format encoding, like JPEG
static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestinationRequestedFileSize";
// Only assert on Debug mode and Simulator
#define SD_CHECK_CGIMAGE_RETAIN_SOURCE DEBUG && TARGET_OS_SIMULATOR && \
((__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_15_0)) || \
((__TV_OS_VERSION_MAX_ALLOWED >= __TVOS_15_0))
// This strip the un-wanted CGImageProperty, like the internal CGImageSourceRef in iOS 15+
// However, CGImageCreateCopy still keep those CGImageProperty, not suit for our use case
static CGImageRef __nullable SDCGImageCreateCopy(CGImageRef cg_nullable image) {
if (!image) return nil;
size_t width = CGImageGetWidth(image);
size_t height = CGImageGetHeight(image);
size_t bitsPerComponent = CGImageGetBitsPerComponent(image);
size_t bitsPerPixel = CGImageGetBitsPerPixel(image);
size_t bytesPerRow = CGImageGetBytesPerRow(image);
CGColorSpaceRef space = CGImageGetColorSpace(image);
CGBitmapInfo bitmapInfo = CGImageGetBitmapInfo(image);
CGDataProviderRef provider = CGImageGetDataProvider(image);
const CGFloat *decode = CGImageGetDecode(image);
bool shouldInterpolate = CGImageGetShouldInterpolate(image);
CGColorRenderingIntent intent = CGImageGetRenderingIntent(image);
CGImageRef newImage = CGImageCreate(width, height, bitsPerComponent, bitsPerPixel, bytesPerRow, space, bitmapInfo, provider, decode, shouldInterpolate, intent);
return newImage;
}
@interface SDImageIOCoderFrame : NSObject
@property (nonatomic, assign) NSUInteger index; // Frame index (zero based)
@ -46,6 +70,7 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
BOOL _finished;
BOOL _preserveAspectRatio;
CGSize _thumbnailSize;
BOOL _lazyDecode;
}
- (void)dealloc
@ -193,7 +218,7 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
return frameDuration;
}
+ (UIImage *)createFrameAtIndex:(NSUInteger)index source:(CGImageSourceRef)source scale:(CGFloat)scale preserveAspectRatio:(BOOL)preserveAspectRatio thumbnailSize:(CGSize)thumbnailSize forceDecode:(BOOL)forceDecode options:(NSDictionary *)options {
+ (UIImage *)createFrameAtIndex:(NSUInteger)index source:(CGImageSourceRef)source scale:(CGFloat)scale preserveAspectRatio:(BOOL)preserveAspectRatio thumbnailSize:(CGSize)thumbnailSize lazyDecode:(BOOL)lazyDecode options:(NSDictionary *)options {
// Some options need to pass to `CGImageSourceCopyPropertiesAtIndex` before `CGImageSourceCreateImageAtIndex`, or ImageIO will ignore them because they parse once :)
// Parse the image properties
NSDictionary *properties = (__bridge_transfer NSDictionary *)CGImageSourceCopyPropertiesAtIndex(source, index, (__bridge CFDictionaryRef)options);
@ -250,7 +275,7 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
}
}
// Check whether output CGImage is decoded
if (forceDecode) {
if (!lazyDecode) {
if (!isDecoded) {
// Use CoreGraphics to trigger immediately decode
CGImageRef decodedImageRef = [SDImageCoderHelper CGImageCreateDecoded:imageRef];
@ -258,13 +283,24 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
imageRef = decodedImageRef;
isDecoded = YES;
}
} else {
// iOS 15+, CGImageRef now retains CGImageSourceRef internally. To workaround its thread-safe issue, we have to strip CGImageSourceRef, using Force-Decode (or have to use SPI `CGImageSetImageSource`), See: https://github.com/SDWebImage/SDWebImage/issues/3273
if (@available(iOS 15, tvOS 15, *)) {
// User pass `lazyDecode == YES`, but we still have to strip the CGImageSourceRef
if (imageRef) {
// CGImageRef newImageRef = CGImageCreateCopy(imageRef);
CGImageRef newImageRef = SDCGImageCreateCopy(imageRef);
CGImageRelease(imageRef);
imageRef = newImageRef;
}
}
}
#if SD_CHECK_CGIMAGE_RETAIN_SOURCE
// Assert here to check CGImageRef should not retain the CGImageSourceRef and has possible thread-safe issue (this is behavior on iOS 15+)
// If assert hit, fire issue to https://github.com/SDWebImage/SDWebImage/issues and we update the condition for this behavior check
extern CGImageSourceRef CGImageGetImageSource(CGImageRef);
NSCAssert(!CGImageGetImageSource(imageRef), @"Animated Coder created CGImageRef should not retain CGImageSourceRef, which may cause thread-safe issue without lock");
#endif
}
#if SD_UIKIT || SD_WATCH
UIImageOrientation imageOrientation = [SDImageCoderHelper imageOrientationFromEXIFOrientation:exifOrientation];
@ -273,6 +309,8 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
UIImage *image = [[UIImage alloc] initWithCGImage:imageRef scale:scale orientation:exifOrientation];
#endif
CGImageRelease(imageRef);
image.sd_isDecoded = isDecoded;
return image;
}
@ -307,6 +345,12 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
preserveAspectRatio = preserveAspectRatioValue.boolValue;
}
BOOL lazyDecode = YES; // Defaults YES for static image coder
NSNumber *lazyDecodeValue = options[SDImageCoderDecodeUseLazyDecoding];
if (lazyDecodeValue != nil) {
lazyDecode = lazyDecodeValue.boolValue;
}
#if SD_MAC
// If don't use thumbnail, prefers the built-in generation of frames (GIF/APNG)
// Which decode frames in time and reduce memory usage
@ -353,12 +397,12 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
BOOL decodeFirstFrame = [options[SDImageCoderDecodeFirstFrameOnly] boolValue];
if (decodeFirstFrame || count <= 1) {
animatedImage = [self.class createFrameAtIndex:0 source:source scale:scale preserveAspectRatio:preserveAspectRatio thumbnailSize:thumbnailSize forceDecode:NO options:nil];
animatedImage = [self.class createFrameAtIndex:0 source:source scale:scale preserveAspectRatio:preserveAspectRatio thumbnailSize:thumbnailSize lazyDecode:lazyDecode options:nil];
} else {
NSMutableArray<SDImageFrame *> *frames = [NSMutableArray arrayWithCapacity:count];
for (size_t i = 0; i < count; i++) {
UIImage *image = [self.class createFrameAtIndex:i source:source scale:scale preserveAspectRatio:preserveAspectRatio thumbnailSize:thumbnailSize forceDecode:NO options:nil];
UIImage *image = [self.class createFrameAtIndex:i source:source scale:scale preserveAspectRatio:preserveAspectRatio thumbnailSize:thumbnailSize lazyDecode:lazyDecode options:nil];
if (!image) {
continue;
}
@ -414,6 +458,12 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
preserveAspectRatio = preserveAspectRatioValue.boolValue;
}
_preserveAspectRatio = preserveAspectRatio;
BOOL lazyDecode = YES; // Defaults YES for static image coder
NSNumber *lazyDecodeValue = options[SDImageCoderDecodeUseLazyDecoding];
if (lazyDecodeValue != nil) {
lazyDecode = lazyDecodeValue.boolValue;
}
_lazyDecode = lazyDecode;
SD_LOCK_INIT(_lock);
#if SD_UIKIT
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didReceiveMemoryWarning:) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
@ -468,7 +518,7 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
if (scaleFactor != nil) {
scale = MAX([scaleFactor doubleValue], 1);
}
image = [self.class createFrameAtIndex:0 source:_imageSource scale:scale preserveAspectRatio:_preserveAspectRatio thumbnailSize:_thumbnailSize forceDecode:NO options:nil];
image = [self.class createFrameAtIndex:0 source:_imageSource scale:scale preserveAspectRatio:_preserveAspectRatio thumbnailSize:_thumbnailSize lazyDecode:_lazyDecode options:nil];
if (image) {
image.sd_imageFormat = self.class.imageFormat;
}
@ -715,28 +765,27 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
- (UIImage *)safeAnimatedImageFrameAtIndex:(NSUInteger)index {
NSDictionary *options;
BOOL forceDecode = NO;
if (@available(iOS 15, tvOS 15, *)) {
// iOS 15+, CGImageRef now retains CGImageSourceRef internally. To workaround its thread-safe issue, we have to strip CGImageSourceRef, using Force-Decode (or have to use SPI `CGImageSetImageSource`), See: https://github.com/SDWebImage/SDWebImage/issues/3273
forceDecode = YES;
BOOL lazyDecode = NO; // Defaults NO for animated image coder
NSNumber *lazyDecodeValue = options[SDImageCoderDecodeUseLazyDecoding];
if (lazyDecodeValue != nil) {
lazyDecode = lazyDecodeValue.boolValue;
}
if (!lazyDecode) {
options = @{
(__bridge NSString *)kCGImageSourceShouldCacheImmediately : @(NO),
(__bridge NSString *)kCGImageSourceShouldCache : @(NO)
};
} else {
// Animated Image should not use the CGContext solution to force decode on lower firmware. Prefers to use Image/IO built in method, which is safer and memory friendly, see https://github.com/SDWebImage/SDWebImage/issues/2961
forceDecode = NO;
options = @{
(__bridge NSString *)kCGImageSourceShouldCacheImmediately : @(YES),
(__bridge NSString *)kCGImageSourceShouldCache : @(YES) // Always cache to reduce CPU usage
};
}
UIImage *image = [self.class createFrameAtIndex:index source:_imageSource scale:_scale preserveAspectRatio:_preserveAspectRatio thumbnailSize:_thumbnailSize forceDecode:forceDecode options:options];
UIImage *image = [self.class createFrameAtIndex:index source:_imageSource scale:_scale preserveAspectRatio:_preserveAspectRatio thumbnailSize:_thumbnailSize lazyDecode:lazyDecode options:options];
if (!image) {
return nil;
}
image.sd_imageFormat = self.class.imageFormat;
image.sd_isDecoded = YES;
return image;
}

View File

@ -28,6 +28,7 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
BOOL _finished;
BOOL _preserveAspectRatio;
CGSize _thumbnailSize;
BOOL _lazyDecode;
}
- (void)dealloc {
@ -112,6 +113,12 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
preserveAspectRatio = preserveAspectRatioValue.boolValue;
}
BOOL lazyDecode = YES; // Defaults YES for static image coder
NSNumber *lazyDecodeValue = options[SDImageCoderDecodeUseLazyDecoding];
if (lazyDecodeValue != nil) {
lazyDecode = lazyDecodeValue.boolValue;
}
NSString *typeIdentifierHint = options[SDImageCoderDecodeTypeIdentifierHint];
if (!typeIdentifierHint) {
// Check file extension and convert to UTI, from: https://stackoverflow.com/questions/1506251/getting-an-uniform-type-identifier-for-a-given-extension
@ -163,7 +170,7 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
thumbnailSize = CGSizeZero;
}
UIImage *image = [SDImageIOAnimatedCoder createFrameAtIndex:0 source:source scale:scale preserveAspectRatio:preserveAspectRatio thumbnailSize:thumbnailSize forceDecode:NO options:decodingOptions];
UIImage *image = [SDImageIOAnimatedCoder createFrameAtIndex:0 source:source scale:scale preserveAspectRatio:preserveAspectRatio thumbnailSize:thumbnailSize lazyDecode:lazyDecode options:decodingOptions];
CFRelease(source);
if (!image) {
return nil;
@ -205,6 +212,12 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
preserveAspectRatio = preserveAspectRatioValue.boolValue;
}
_preserveAspectRatio = preserveAspectRatio;
BOOL lazyDecode = YES; // Defaults YES for static image coder
NSNumber *lazyDecodeValue = options[SDImageCoderDecodeUseLazyDecoding];
if (lazyDecodeValue != nil) {
lazyDecode = lazyDecodeValue.boolValue;
}
_lazyDecode = lazyDecode;
#if SD_UIKIT
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didReceiveMemoryWarning:) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
#endif
@ -255,7 +268,7 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
if (scaleFactor != nil) {
scale = MAX([scaleFactor doubleValue], 1);
}
image = [SDImageIOAnimatedCoder createFrameAtIndex:0 source:_imageSource scale:scale preserveAspectRatio:_preserveAspectRatio thumbnailSize:_thumbnailSize forceDecode:NO options:nil];
image = [SDImageIOAnimatedCoder createFrameAtIndex:0 source:_imageSource scale:scale preserveAspectRatio:_preserveAspectRatio thumbnailSize:_thumbnailSize lazyDecode:_lazyDecode options:nil];
if (image) {
CFStringRef uttype = CGImageSourceGetType(_imageSource);
image.sd_imageFormat = [NSData sd_imageFormatFromUTType:uttype];

View File

@ -77,14 +77,17 @@ UIImage * _Nullable SDImageLoaderDecodeImageData(NSData * _Nonnull imageData, NS
}
if (image) {
BOOL shouldDecode = !SD_OPTIONS_CONTAINS(options, SDWebImageAvoidDecodeImage);
if ([image.class conformsToProtocol:@protocol(SDAnimatedImage)]) {
BOOL lazyDecode = [coderOptions[SDImageCoderDecodeUseLazyDecoding] boolValue];
if (lazyDecode) {
// lazyDecode = NO means we should not forceDecode, highest priority
shouldDecode = NO;
} else if ([image.class conformsToProtocol:@protocol(SDAnimatedImage)]) {
// `SDAnimatedImage` do not decode
shouldDecode = NO;
} else if (image.sd_isAnimated) {
// animated image do not decode
shouldDecode = NO;
}
if (shouldDecode) {
image = [SDImageCoderHelper decodedImageWithImage:image];
}
@ -157,7 +160,11 @@ UIImage * _Nullable SDImageLoaderDecodeProgressiveImageData(NSData * _Nonnull im
}
if (image) {
BOOL shouldDecode = !SD_OPTIONS_CONTAINS(options, SDWebImageAvoidDecodeImage);
if ([image.class conformsToProtocol:@protocol(SDAnimatedImage)]) {
BOOL lazyDecode = [coderOptions[SDImageCoderDecodeUseLazyDecoding] boolValue];
if (lazyDecode) {
// lazyDecode = NO means we should not forceDecode, highest priority
shouldDecode = NO;
} else if ([image.class conformsToProtocol:@protocol(SDAnimatedImage)]) {
// `SDAnimatedImage` do not decode
shouldDecode = NO;
} else if (image.sd_isAnimated) {
@ -167,10 +174,10 @@ UIImage * _Nullable SDImageLoaderDecodeProgressiveImageData(NSData * _Nonnull im
if (shouldDecode) {
image = [SDImageCoderHelper decodedImageWithImage:image];
}
// mark the image as progressive (completed one are not mark as progressive)
image.sd_isIncremental = !finished;
// assign the decode options, to let manager check whether to re-decode if needed
image.sd_decodeOptions = coderOptions;
// mark the image as progressive (completed one are not mark as progressive)
image.sd_isIncremental = !finished;
}
return image;

View File

@ -169,7 +169,8 @@ typedef NS_OPTIONS(NSUInteger, SDWebImageOptions) {
/**
* By default, we will decode the image in the background during cache query and download from the network. This can help to improve performance because when rendering image on the screen, it need to be firstly decoded. But this happen on the main queue by Core Animation.
* However, this process may increase the memory usage as well. If you are experiencing a issue due to excessive memory consumption, This flag can prevent decode the image.
* However, this process may increase the memory usage as well. If you are experiencing an issue due to excessive memory consumption, This flag can prevent decode the image.
* @note 5.14.0 introduce `SDImageCoderDecodeUseLazyDecoding`, use that for better control from codec, instead of post-processing. Which acts the similar like this option but works for SDAnimatedImage as well (this one does not)
*/
SDWebImageAvoidDecodeImage = 1 << 18,

View File

@ -602,7 +602,7 @@ didReceiveResponse:(NSURLResponse *)response
}
}];
}
if (@available(iOS 13.0, *)) {
if (@available(iOS 13.0, tvOS 13.0, macOS 10.15, watchOS 6.0, *)) {
[self.coderQueue addBarrierBlock:^{
@strongify(self);
if (!self) {

View File

@ -32,7 +32,7 @@
+ (NSTimeInterval)frameDurationAtIndex:(NSUInteger)index source:(nonnull CGImageSourceRef)source;
+ (NSUInteger)imageLoopCountWithSource:(nonnull CGImageSourceRef)source;
+ (nullable UIImage *)createFrameAtIndex:(NSUInteger)index source:(nonnull CGImageSourceRef)source scale:(CGFloat)scale preserveAspectRatio:(BOOL)preserveAspectRatio thumbnailSize:(CGSize)thumbnailSize forceDecode:(BOOL)forceDecode options:(nullable NSDictionary *)options;
+ (nullable UIImage *)createFrameAtIndex:(NSUInteger)index source:(nonnull CGImageSourceRef)source scale:(CGFloat)scale preserveAspectRatio:(BOOL)preserveAspectRatio thumbnailSize:(CGSize)thumbnailSize lazyDecode:(BOOL)lazyDecode options:(nullable NSDictionary *)options;
+ (BOOL)canEncodeToFormat:(SDImageFormat)format;
+ (BOOL)canDecodeFromFormat:(SDImageFormat)format;

View File

@ -369,7 +369,14 @@
SDImageCoderEncodeMaxPixelSize: @(thumbnailSize)
}];
UIImage *encodedImage = [UIImage sd_imageWithData:encodedData];
// Encode keep aspect ratio, but will use scale down instead of scale up if we strip the image-io related info (to fix some Apple's bug)
// See more in `SDCGImageCreateCopy`
expect(image.sd_isDecoded).beFalsy();
if (@available(iOS 15, tvOS 15, *)) {
expect(encodedImage.size).equal(CGSizeMake(4000, 2628));
} else {
expect(encodedImage.size).equal(CGSizeMake(4000, 2629));
}
}
- (void)test24ThatScaleSizeCalculation {

View File

@ -345,19 +345,22 @@
- (void)test17ThatMinimumProgressIntervalWorks {
XCTestExpectation *expectation = [self expectationWithDescription:@"Minimum progress interval"];
SDWebImageDownloaderConfig *config = SDWebImageDownloaderConfig.defaultDownloaderConfig;
config.minimumProgressInterval = 0.51; // This will make the progress only callback twice (once is 51%, another is 100%)
config.minimumProgressInterval = 0.51; // This will make the progress only callback at most 4 times (-1, 0%, 51%, 100%)
SDWebImageDownloader *downloader = [[SDWebImageDownloader alloc] initWithConfig:config];
NSURL *imageURL = [NSURL URLWithString:@"https://raw.githubusercontent.com/recurser/exif-orientation-examples/master/Landscape_1.jpg"];
__block NSUInteger allProgressCount = 0; // All progress (including operation start / first HTTP response, etc)
__block BOOL completed = NO;
[downloader downloadImageWithURL:imageURL options:0 progress:^(NSInteger receivedSize, NSInteger expectedSize, NSURL * _Nullable targetURL) {
allProgressCount++;
} completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, BOOL finished) {
if (completed) {
return;
}
if (allProgressCount > 0) {
[expectation fulfill];
allProgressCount = 0;
return;
completed = YES;
} else {
XCTFail(@"Progress callback more than once");
XCTFail(@"Completed callback before progress update");
}
}];