503 lines
21 KiB
Objective-C
503 lines
21 KiB
Objective-C
/*
|
|
* This file is part of the SDWebImage package.
|
|
* (c) Olivier Poitrey <rs@dailymotion.com>
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
#import "SDWebImageDownloaderOperation.h"
|
|
#import "SDWebImageError.h"
|
|
#import "SDInternalMacros.h"
|
|
|
|
// iOS 8 Foundation.framework extern these symbol but the define is in CFNetwork.framework. We just fix this without import CFNetwork.framework
|
|
#if (__IPHONE_OS_VERSION_MIN_REQUIRED && __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_9_0)
|
|
const float NSURLSessionTaskPriorityHigh = 0.75;
|
|
const float NSURLSessionTaskPriorityDefault = 0.5;
|
|
const float NSURLSessionTaskPriorityLow = 0.25;
|
|
#endif
|
|
|
|
static NSString *const kProgressCallbackKey = @"progress";
|
|
static NSString *const kCompletedCallbackKey = @"completed";
|
|
|
|
typedef NSMutableDictionary<NSString *, id> SDCallbacksDictionary;
|
|
|
|
@interface SDWebImageDownloaderOperation ()
|
|
|
|
@property (strong, nonatomic, nonnull) NSMutableArray<SDCallbacksDictionary *> *callbackBlocks;
|
|
|
|
@property (assign, nonatomic, readwrite) SDWebImageDownloaderOptions options;
|
|
@property (copy, nonatomic, readwrite, nullable) SDWebImageContext *context;
|
|
|
|
@property (assign, nonatomic, getter = isExecuting) BOOL executing;
|
|
@property (assign, nonatomic, getter = isFinished) BOOL finished;
|
|
@property (strong, nonatomic, nullable) NSMutableData *imageData;
|
|
@property (copy, nonatomic, nullable) NSData *cachedData; // for `SDWebImageDownloaderIgnoreCachedResponse`
|
|
@property (assign, nonatomic) NSUInteger expectedSize; // may be 0
|
|
@property (assign, nonatomic) NSUInteger receivedSize;
|
|
@property (strong, nonatomic, nullable, readwrite) NSURLResponse *response;
|
|
@property (strong, nonatomic, nullable) NSError *responseError;
|
|
@property (assign, nonatomic) double previousProgress; // previous progress percent
|
|
|
|
// This is weak because it is injected by whoever manages this session. If this gets nil-ed out, we won't be able to run
|
|
// the task associated with this operation
|
|
@property (weak, nonatomic, nullable) NSURLSession *unownedSession;
|
|
// This is set if we're using not using an injected NSURLSession. We're responsible of invalidating this one
|
|
@property (strong, nonatomic, nullable) NSURLSession *ownedSession;
|
|
|
|
@property (strong, nonatomic, readwrite, nullable) NSURLSessionTask *dataTask;
|
|
|
|
@property (strong, nonatomic, nonnull) dispatch_semaphore_t callbacksLock; // a lock to keep the access to `callbackBlocks` thread-safe
|
|
|
|
@property (strong, nonatomic, nonnull) dispatch_queue_t coderQueue; // the queue to do image decoding
|
|
#if SD_UIKIT
|
|
@property (assign, nonatomic) UIBackgroundTaskIdentifier backgroundTaskId;
|
|
#endif
|
|
|
|
@end
|
|
|
|
@implementation SDWebImageDownloaderOperation
|
|
|
|
@synthesize executing = _executing;
|
|
@synthesize finished = _finished;
|
|
|
|
- (nonnull instancetype)init {
|
|
return [self initWithRequest:nil inSession:nil options:0];
|
|
}
|
|
|
|
- (instancetype)initWithRequest:(NSURLRequest *)request inSession:(NSURLSession *)session options:(SDWebImageDownloaderOptions)options {
|
|
return [self initWithRequest:request inSession:session options:options context:nil];
|
|
}
|
|
|
|
- (nonnull instancetype)initWithRequest:(nullable NSURLRequest *)request
|
|
inSession:(nullable NSURLSession *)session
|
|
options:(SDWebImageDownloaderOptions)options
|
|
context:(nullable SDWebImageContext *)context {
|
|
if ((self = [super init])) {
|
|
_request = [request copy];
|
|
_options = options;
|
|
_context = [context copy];
|
|
_callbackBlocks = [NSMutableArray new];
|
|
_executing = NO;
|
|
_finished = NO;
|
|
_expectedSize = 0;
|
|
_unownedSession = session;
|
|
_callbacksLock = dispatch_semaphore_create(1);
|
|
_coderQueue = dispatch_queue_create("com.hackemist.SDWebImageDownloaderOperationCoderQueue", DISPATCH_QUEUE_SERIAL);
|
|
#if SD_UIKIT
|
|
_backgroundTaskId = UIBackgroundTaskInvalid;
|
|
#endif
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (nullable id)addHandlersForProgress:(nullable SDWebImageDownloaderProgressBlock)progressBlock
|
|
completed:(nullable SDWebImageDownloaderCompletedBlock)completedBlock {
|
|
SDCallbacksDictionary *callbacks = [NSMutableDictionary new];
|
|
if (progressBlock) callbacks[kProgressCallbackKey] = [progressBlock copy];
|
|
if (completedBlock) callbacks[kCompletedCallbackKey] = [completedBlock copy];
|
|
SD_LOCK(self.callbacksLock);
|
|
[self.callbackBlocks addObject:callbacks];
|
|
SD_UNLOCK(self.callbacksLock);
|
|
return callbacks;
|
|
}
|
|
|
|
- (nullable NSArray<id> *)callbacksForKey:(NSString *)key {
|
|
SD_LOCK(self.callbacksLock);
|
|
NSMutableArray<id> *callbacks = [[self.callbackBlocks valueForKey:key] mutableCopy];
|
|
SD_UNLOCK(self.callbacksLock);
|
|
// We need to remove [NSNull null] because there might not always be a progress block for each callback
|
|
[callbacks removeObjectIdenticalTo:[NSNull null]];
|
|
return [callbacks copy]; // strip mutability here
|
|
}
|
|
|
|
- (BOOL)cancel:(nullable id)token {
|
|
BOOL shouldCancel = NO;
|
|
SD_LOCK(self.callbacksLock);
|
|
[self.callbackBlocks removeObjectIdenticalTo:token];
|
|
if (self.callbackBlocks.count == 0) {
|
|
shouldCancel = YES;
|
|
}
|
|
SD_UNLOCK(self.callbacksLock);
|
|
if (shouldCancel) {
|
|
[self cancel];
|
|
}
|
|
return shouldCancel;
|
|
}
|
|
|
|
- (void)start {
|
|
@synchronized (self) {
|
|
if (self.isCancelled) {
|
|
self.finished = YES;
|
|
[self reset];
|
|
return;
|
|
}
|
|
|
|
#if SD_UIKIT
|
|
Class UIApplicationClass = NSClassFromString(@"UIApplication");
|
|
BOOL hasApplication = UIApplicationClass && [UIApplicationClass respondsToSelector:@selector(sharedApplication)];
|
|
if (hasApplication && [self shouldContinueWhenAppEntersBackground]) {
|
|
__weak typeof(self) wself = self;
|
|
UIApplication * app = [UIApplicationClass performSelector:@selector(sharedApplication)];
|
|
self.backgroundTaskId = [app beginBackgroundTaskWithExpirationHandler:^{
|
|
[wself cancel];
|
|
}];
|
|
}
|
|
#endif
|
|
NSURLSession *session = self.unownedSession;
|
|
if (!session) {
|
|
NSURLSessionConfiguration *sessionConfig = [NSURLSessionConfiguration defaultSessionConfiguration];
|
|
sessionConfig.timeoutIntervalForRequest = 15;
|
|
|
|
/**
|
|
* Create the session for this task
|
|
* We send nil as delegate queue so that the session creates a serial operation queue for performing all delegate
|
|
* method calls and completion handler calls.
|
|
*/
|
|
session = [NSURLSession sessionWithConfiguration:sessionConfig
|
|
delegate:self
|
|
delegateQueue:nil];
|
|
self.ownedSession = session;
|
|
}
|
|
|
|
if (self.options & SDWebImageDownloaderIgnoreCachedResponse) {
|
|
// Grab the cached data for later check
|
|
NSURLCache *URLCache = session.configuration.URLCache;
|
|
if (!URLCache) {
|
|
URLCache = [NSURLCache sharedURLCache];
|
|
}
|
|
NSCachedURLResponse *cachedResponse;
|
|
// NSURLCache's `cachedResponseForRequest:` is not thread-safe, see https://developer.apple.com/documentation/foundation/nsurlcache#2317483
|
|
@synchronized (URLCache) {
|
|
cachedResponse = [URLCache cachedResponseForRequest:self.request];
|
|
}
|
|
if (cachedResponse) {
|
|
self.cachedData = cachedResponse.data;
|
|
}
|
|
}
|
|
|
|
self.dataTask = [session dataTaskWithRequest:self.request];
|
|
self.executing = YES;
|
|
}
|
|
|
|
if (self.dataTask) {
|
|
if (self.options & SDWebImageDownloaderHighPriority) {
|
|
self.dataTask.priority = NSURLSessionTaskPriorityHigh;
|
|
} else if (self.options & SDWebImageDownloaderLowPriority) {
|
|
self.dataTask.priority = NSURLSessionTaskPriorityLow;
|
|
}
|
|
[self.dataTask resume];
|
|
for (SDWebImageDownloaderProgressBlock progressBlock in [self callbacksForKey:kProgressCallbackKey]) {
|
|
progressBlock(0, NSURLResponseUnknownLength, self.request.URL);
|
|
}
|
|
__block typeof(self) strongSelf = self;
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadStartNotification object:strongSelf];
|
|
});
|
|
} else {
|
|
[self callCompletionBlocksWithError:[NSError errorWithDomain:SDWebImageErrorDomain code:SDWebImageErrorInvalidDownloadOperation userInfo:@{NSLocalizedDescriptionKey : @"Task can't be initialized"}]];
|
|
[self done];
|
|
}
|
|
}
|
|
|
|
- (void)cancel {
|
|
@synchronized (self) {
|
|
[self cancelInternal];
|
|
}
|
|
}
|
|
|
|
- (void)cancelInternal {
|
|
if (self.isFinished) return;
|
|
[super cancel];
|
|
|
|
if (self.dataTask) {
|
|
[self.dataTask cancel];
|
|
__block typeof(self) strongSelf = self;
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadStopNotification object:strongSelf];
|
|
});
|
|
|
|
// As we cancelled the task, its callback won't be called and thus won't
|
|
// maintain the isFinished and isExecuting flags.
|
|
if (self.isExecuting) self.executing = NO;
|
|
if (!self.isFinished) self.finished = YES;
|
|
}
|
|
|
|
[self reset];
|
|
}
|
|
|
|
- (void)done {
|
|
self.finished = YES;
|
|
self.executing = NO;
|
|
[self reset];
|
|
}
|
|
|
|
- (void)reset {
|
|
SD_LOCK(self.callbacksLock);
|
|
[self.callbackBlocks removeAllObjects];
|
|
SD_UNLOCK(self.callbacksLock);
|
|
|
|
@synchronized (self) {
|
|
self.dataTask = nil;
|
|
|
|
if (self.ownedSession) {
|
|
[self.ownedSession invalidateAndCancel];
|
|
self.ownedSession = nil;
|
|
}
|
|
|
|
#if SD_UIKIT
|
|
if (self.backgroundTaskId != UIBackgroundTaskInvalid) {
|
|
// If backgroundTaskId != UIBackgroundTaskInvalid, sharedApplication is always exist
|
|
UIApplication * app = [UIApplication performSelector:@selector(sharedApplication)];
|
|
[app endBackgroundTask:self.backgroundTaskId];
|
|
self.backgroundTaskId = UIBackgroundTaskInvalid;
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
|
|
- (void)setFinished:(BOOL)finished {
|
|
[self willChangeValueForKey:@"isFinished"];
|
|
_finished = finished;
|
|
[self didChangeValueForKey:@"isFinished"];
|
|
}
|
|
|
|
- (void)setExecuting:(BOOL)executing {
|
|
[self willChangeValueForKey:@"isExecuting"];
|
|
_executing = executing;
|
|
[self didChangeValueForKey:@"isExecuting"];
|
|
}
|
|
|
|
- (BOOL)isConcurrent {
|
|
return YES;
|
|
}
|
|
|
|
#pragma mark NSURLSessionDataDelegate
|
|
|
|
- (void)URLSession:(NSURLSession *)session
|
|
dataTask:(NSURLSessionDataTask *)dataTask
|
|
didReceiveResponse:(NSURLResponse *)response
|
|
completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler {
|
|
NSURLSessionResponseDisposition disposition = NSURLSessionResponseAllow;
|
|
NSInteger expected = (NSInteger)response.expectedContentLength;
|
|
expected = expected > 0 ? expected : 0;
|
|
self.expectedSize = expected;
|
|
self.response = response;
|
|
NSInteger statusCode = [response respondsToSelector:@selector(statusCode)] ? ((NSHTTPURLResponse *)response).statusCode : 200;
|
|
BOOL valid = statusCode >= 200 && statusCode < 400;
|
|
if (!valid) {
|
|
self.responseError = [NSError errorWithDomain:SDWebImageErrorDomain code:SDWebImageErrorInvalidDownloadStatusCode userInfo:@{SDWebImageErrorDownloadStatusCodeKey : @(statusCode)}];
|
|
}
|
|
//'304 Not Modified' is an exceptional one
|
|
//URLSession current behavior will return 200 status code when the server respond 304 and URLCache hit. But this is not a standard behavior and we just add a check
|
|
if (statusCode == 304 && !self.cachedData) {
|
|
valid = NO;
|
|
self.responseError = [NSError errorWithDomain:SDWebImageErrorDomain code:SDWebImageErrorCacheNotModified userInfo:nil];
|
|
}
|
|
|
|
if (valid) {
|
|
for (SDWebImageDownloaderProgressBlock progressBlock in [self callbacksForKey:kProgressCallbackKey]) {
|
|
progressBlock(0, expected, self.request.URL);
|
|
}
|
|
} else {
|
|
// Status code invalid and marked as cancelled. Do not call `[self.dataTask cancel]` which may mass up URLSession life cycle
|
|
disposition = NSURLSessionResponseCancel;
|
|
}
|
|
__block typeof(self) strongSelf = self;
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadReceiveResponseNotification object:strongSelf];
|
|
});
|
|
|
|
if (completionHandler) {
|
|
completionHandler(disposition);
|
|
}
|
|
}
|
|
|
|
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
|
|
if (!self.imageData) {
|
|
self.imageData = [[NSMutableData alloc] initWithCapacity:self.expectedSize];
|
|
}
|
|
[self.imageData appendData:data];
|
|
|
|
self.receivedSize = self.imageData.length;
|
|
if (self.expectedSize == 0) {
|
|
// Unknown expectedSize, immediately call progressBlock and return
|
|
for (SDWebImageDownloaderProgressBlock progressBlock in [self callbacksForKey:kProgressCallbackKey]) {
|
|
progressBlock(self.receivedSize, self.expectedSize, self.request.URL);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Get the finish status
|
|
BOOL finished = (self.receivedSize >= self.expectedSize);
|
|
// Get the current progress
|
|
double currentProgress = (double)self.receivedSize / (double)self.expectedSize;
|
|
double previousProgress = self.previousProgress;
|
|
double progressInterval = currentProgress - previousProgress;
|
|
// Check if we need callback progress
|
|
if (!finished && (progressInterval < self.minimumProgressInterval)) {
|
|
return;
|
|
}
|
|
self.previousProgress = currentProgress;
|
|
|
|
if (self.options & SDWebImageDownloaderProgressiveLoad) {
|
|
// Get the image data
|
|
NSData *imageData = [self.imageData copy];
|
|
|
|
// progressive decode the image in coder queue
|
|
dispatch_async(self.coderQueue, ^{
|
|
@autoreleasepool {
|
|
UIImage *image = SDImageLoaderDecodeProgressiveImageData(imageData, self.request.URL, finished, self, [[self class] imageOptionsFromDownloaderOptions:self.options], self.context);
|
|
if (image) {
|
|
// We do not keep the progressive decoding image even when `finished`=YES. Because they are for view rendering but not take full function from downloader options. And some coders implementation may not keep consistent between progressive decoding and normal decoding.
|
|
|
|
[self callCompletionBlocksWithImage:image imageData:nil error:nil finished:NO];
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
for (SDWebImageDownloaderProgressBlock progressBlock in [self callbacksForKey:kProgressCallbackKey]) {
|
|
progressBlock(self.receivedSize, self.expectedSize, self.request.URL);
|
|
}
|
|
}
|
|
|
|
- (void)URLSession:(NSURLSession *)session
|
|
dataTask:(NSURLSessionDataTask *)dataTask
|
|
willCacheResponse:(NSCachedURLResponse *)proposedResponse
|
|
completionHandler:(void (^)(NSCachedURLResponse *cachedResponse))completionHandler {
|
|
|
|
NSCachedURLResponse *cachedResponse = proposedResponse;
|
|
|
|
if (!(self.options & SDWebImageDownloaderUseNSURLCache)) {
|
|
// Prevents caching of responses
|
|
cachedResponse = nil;
|
|
}
|
|
if (completionHandler) {
|
|
completionHandler(cachedResponse);
|
|
}
|
|
}
|
|
|
|
#pragma mark NSURLSessionTaskDelegate
|
|
|
|
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
|
|
@synchronized(self) {
|
|
self.dataTask = nil;
|
|
__block typeof(self) strongSelf = self;
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadStopNotification object:strongSelf];
|
|
if (!error) {
|
|
[[NSNotificationCenter defaultCenter] postNotificationName:SDWebImageDownloadFinishNotification object:strongSelf];
|
|
}
|
|
});
|
|
}
|
|
|
|
// make sure to call `[self done]` to mark operation as finished
|
|
if (error) {
|
|
// custom error instead of URLSession error
|
|
if (self.responseError) {
|
|
error = self.responseError;
|
|
}
|
|
[self callCompletionBlocksWithError:error];
|
|
[self done];
|
|
} else {
|
|
if ([self callbacksForKey:kCompletedCallbackKey].count > 0) {
|
|
NSData *imageData = [self.imageData copy];
|
|
self.imageData = nil;
|
|
if (imageData) {
|
|
/** if you specified to only use cached data via `SDWebImageDownloaderIgnoreCachedResponse`,
|
|
* then we should check if the cached data is equal to image data
|
|
*/
|
|
if (self.options & SDWebImageDownloaderIgnoreCachedResponse && [self.cachedData isEqualToData:imageData]) {
|
|
self.responseError = [NSError errorWithDomain:SDWebImageErrorDomain code:SDWebImageErrorCacheNotModified userInfo:nil];
|
|
// call completion block with not modified error
|
|
[self callCompletionBlocksWithError:self.responseError];
|
|
[self done];
|
|
} else {
|
|
// decode the image in coder queue
|
|
dispatch_async(self.coderQueue, ^{
|
|
@autoreleasepool {
|
|
UIImage *image = SDImageLoaderDecodeImageData(imageData, self.request.URL, [[self class] imageOptionsFromDownloaderOptions:self.options], self.context);
|
|
CGSize imageSize = image.size;
|
|
if (imageSize.width == 0 || imageSize.height == 0) {
|
|
[self callCompletionBlocksWithError:[NSError errorWithDomain:SDWebImageErrorDomain code:SDWebImageErrorBadImageData userInfo:@{NSLocalizedDescriptionKey : @"Downloaded image has 0 pixels"}]];
|
|
} else {
|
|
[self callCompletionBlocksWithImage:image imageData:imageData error:nil finished:YES];
|
|
}
|
|
[self done];
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
[self callCompletionBlocksWithError:[NSError errorWithDomain:SDWebImageErrorDomain code:SDWebImageErrorBadImageData userInfo:@{NSLocalizedDescriptionKey : @"Image data is nil"}]];
|
|
[self done];
|
|
}
|
|
} else {
|
|
[self done];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler {
|
|
|
|
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
|
|
__block NSURLCredential *credential = nil;
|
|
|
|
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
|
|
if (!(self.options & SDWebImageDownloaderAllowInvalidSSLCertificates)) {
|
|
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
|
|
} else {
|
|
credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
|
|
disposition = NSURLSessionAuthChallengeUseCredential;
|
|
}
|
|
} else {
|
|
if (challenge.previousFailureCount == 0) {
|
|
if (self.credential) {
|
|
credential = self.credential;
|
|
disposition = NSURLSessionAuthChallengeUseCredential;
|
|
} else {
|
|
disposition = NSURLSessionAuthChallengeCancelAuthenticationChallenge;
|
|
}
|
|
} else {
|
|
disposition = NSURLSessionAuthChallengeCancelAuthenticationChallenge;
|
|
}
|
|
}
|
|
|
|
if (completionHandler) {
|
|
completionHandler(disposition, credential);
|
|
}
|
|
}
|
|
|
|
#pragma mark Helper methods
|
|
+ (SDWebImageOptions)imageOptionsFromDownloaderOptions:(SDWebImageDownloaderOptions)downloadOptions {
|
|
SDWebImageOptions options = 0;
|
|
if (downloadOptions & SDWebImageDownloaderScaleDownLargeImages) options |= SDWebImageScaleDownLargeImages;
|
|
if (downloadOptions & SDWebImageDownloaderDecodeFirstFrameOnly) options |= SDWebImageDecodeFirstFrameOnly;
|
|
if (downloadOptions & SDWebImageDownloaderPreloadAllFrames) options |= SDWebImagePreloadAllFrames;
|
|
if (downloadOptions & SDWebImageDownloaderAvoidDecodeImage) options |= SDWebImageAvoidDecodeImage;
|
|
|
|
return options;
|
|
}
|
|
|
|
- (BOOL)shouldContinueWhenAppEntersBackground {
|
|
return self.options & SDWebImageDownloaderContinueInBackground;
|
|
}
|
|
|
|
- (void)callCompletionBlocksWithError:(nullable NSError *)error {
|
|
[self callCompletionBlocksWithImage:nil imageData:nil error:error finished:YES];
|
|
}
|
|
|
|
- (void)callCompletionBlocksWithImage:(nullable UIImage *)image
|
|
imageData:(nullable NSData *)imageData
|
|
error:(nullable NSError *)error
|
|
finished:(BOOL)finished {
|
|
NSArray<id> *completionBlocks = [self callbacksForKey:kCompletedCallbackKey];
|
|
dispatch_main_async_safe(^{
|
|
for (SDWebImageDownloaderCompletedBlock completedBlock in completionBlocks) {
|
|
completedBlock(image, imageData, error, finished);
|
|
}
|
|
});
|
|
}
|
|
|
|
@end
|