From 789cea118d1038fe98c1413fd39c37883f8e0138 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Fri, 29 Sep 2017 10:26:53 -0400 Subject: [PATCH] Pull out GifDownloader class. // FREEBIE --- Signal.xcodeproj/project.pbxproj | 4 + .../GifPicker/GifPickerCell.swift | 2 +- Signal/src/network/GifDownloader.swift | 286 ++++++++++++++++++ Signal/src/network/GifManager.swift | 274 +---------------- 4 files changed, 298 insertions(+), 268 deletions(-) create mode 100644 Signal/src/network/GifDownloader.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index c2ab286c5..cb4ef9e0c 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ 34CE88EC1F3237260098030F /* OWSProfileManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 34CE88EA1F3237260098030F /* OWSProfileManager.m */; }; 34CE88ED1F3237260098030F /* ProfileFetcherJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CE88EB1F3237260098030F /* ProfileFetcherJob.swift */; }; 34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */; }; + 34D1F0521F7E8EA30066283D /* GifDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34D1F0511F7E8EA30066283D /* GifDownloader.swift */; }; 34D5CC961EA6AFAD005515DB /* OWSContactsSyncing.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CC951EA6AFAD005515DB /* OWSContactsSyncing.m */; }; 34D5CCA91EAE3D30005515DB /* AvatarViewHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CCA81EAE3D30005515DB /* AvatarViewHelper.m */; }; 34D5CCB11EAE7E7F005515DB /* SelectRecipientViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 34D5CCB01EAE7E7F005515DB /* SelectRecipientViewController.m */; }; @@ -551,6 +552,7 @@ 34CE88EA1F3237260098030F /* OWSProfileManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSProfileManager.m; sourceTree = ""; }; 34CE88EB1F3237260098030F /* ProfileFetcherJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileFetcherJob.swift; sourceTree = ""; }; 34D1F04F1F7D45A60066283D /* GifPickerCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifPickerCell.swift; sourceTree = ""; }; + 34D1F0511F7E8EA30066283D /* GifDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GifDownloader.swift; sourceTree = ""; }; 34D5CC941EA6AFAD005515DB /* OWSContactsSyncing.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSContactsSyncing.h; sourceTree = ""; }; 34D5CC951EA6AFAD005515DB /* OWSContactsSyncing.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSContactsSyncing.m; sourceTree = ""; }; 34D5CC981EA6EB79005515DB /* OWSMessageCollectionViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSMessageCollectionViewCell.h; sourceTree = ""; }; @@ -1425,6 +1427,7 @@ 76EB041D18170B33006006FC /* network */ = { isa = PBXGroup; children = ( + 34D1F0511F7E8EA30066283D /* GifDownloader.swift */, 3430FE171F7751D4000EC51B /* GifManager.swift */, B6B9ECFA198B31BA00C620D3 /* PushManager.h */, B6B9ECFB198B31BA00C620D3 /* PushManager.m */, @@ -2309,6 +2312,7 @@ 45464DBC1DFA041F001D3FD6 /* DataChannelMessage.swift in Sources */, 34E3E5681EC4B19400495BAC /* AudioProgressView.swift in Sources */, 3448BFCF1EDF0EA7005B2D69 /* OWSMessagesComposerTextView.m in Sources */, + 34D1F0521F7E8EA30066283D /* GifDownloader.swift in Sources */, 450DF2051E0D74AC003D14BE /* Platform.swift in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, 3472229F1EB22FFE00E53955 /* AddToGroupViewController.m in Sources */, diff --git a/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift b/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift index 22e63a9ba..6586bb639 100644 --- a/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift +++ b/Signal/src/ViewControllers/GifPicker/GifPickerCell.swift @@ -77,7 +77,7 @@ class GifPickerCell: UICollectionViewCell { } Logger.verbose("\(TAG) picked rendition: \(rendition.name)") - assetRequest = GifManager.sharedInstance.downloadAssetAsync(rendition:rendition, + assetRequest = GifDownloader.sharedInstance.downloadAssetAsync(rendition:rendition, success: { [weak self] asset in guard let strongSelf = self else { return } strongSelf.clearAssetRequest() diff --git a/Signal/src/network/GifDownloader.swift b/Signal/src/network/GifDownloader.swift new file mode 100644 index 000000000..c78cb45b3 --- /dev/null +++ b/Signal/src/network/GifDownloader.swift @@ -0,0 +1,286 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import Foundation +import ObjectiveC + +@objc class GiphyAssetRequest: NSObject { + static let TAG = "[GiphyAssetRequest]" + + let rendition: GiphyRendition + let success: ((GiphyAsset) -> Void) + let failure: (() -> Void) + var wasCancelled = false + var assetFilePath: String? + + init(rendition: GiphyRendition, + success:@escaping ((GiphyAsset) -> Void), + failure:@escaping (() -> Void) + ) { + self.rendition = rendition + self.success = success + self.failure = failure + } + + public func cancel() { + wasCancelled = true + } +} + +@objc class GiphyAsset: NSObject { + static let TAG = "[GiphyAsset]" + + let rendition: GiphyRendition + let filePath: String + + init(rendition: GiphyRendition, + filePath: String) { + self.rendition = rendition + self.filePath = filePath + } + + deinit { + let filePathCopy = filePath + DispatchQueue.global().async { + do { + let fileManager = FileManager.default + try fileManager.removeItem(atPath:filePathCopy) + } catch let error as NSError { + owsFail("\(GiphyAsset.TAG) file cleanup failed: \(filePathCopy), \(error)") + } + } + } +} + +private var URLSessionTask_GiphyAssetRequest: UInt8 = 0 + +extension URLSessionTask { + var assetRequest: GiphyAssetRequest { + get { + return objc_getAssociatedObject(self, &URLSessionTask_GiphyAssetRequest) as! GiphyAssetRequest + } + set { + objc_setAssociatedObject(self, &URLSessionTask_GiphyAssetRequest, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +@objc class GifDownloader: NSObject, URLSessionTaskDelegate, URLSessionDownloadDelegate { + + // MARK: - Properties + + static let TAG = "[GifDownloader]" + + static let sharedInstance = GifDownloader() + + private let operationQueue = OperationQueue() + + // Force usage as a singleton + override private init() {} + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private let kGiphyBaseURL = "https://api.giphy.com/" + + private func giphyDownloadSession() -> URLSession? { +// guard let baseUrl = NSURL(string:kGiphyBaseURL) else { +// Logger.error("\(GifDownloader.TAG) Invalid base URL.") +// return nil +// } + // TODO: Is this right? + let configuration = URLSessionConfiguration.ephemeral + // TODO: Is this right? + configuration.connectionProxyDictionary = [ + kCFProxyHostNameKey as String: "giphy-proxy-production.whispersystems.org", + kCFProxyPortNumberKey as String: "80", + kCFProxyTypeKey as String: kCFProxyTypeHTTPS + ] + configuration.urlCache = nil + configuration.requestCachePolicy = .reloadIgnoringCacheData + let session = URLSession(configuration:configuration, delegate:self, delegateQueue:operationQueue) + return session +// NSURLSession * session = [NSURLSession sessionWithConfiguration:configuration]; +// +// let sessionManager = AFHTTPSessionManager(baseURL:baseUrl as URL, +// sessionConfiguration:sessionConf) +// sessionManager.requestSerializer = AFJSONRequestSerializer() +// sessionManager.responseSerializer = AFJSONResponseSerializer() +// +// return sessionManager + } + + // TODO: Use a proper cache. + private var assetMap = [NSURL: GiphyAsset]() + // TODO: We could use a proper queue. + private var assetRequestQueue = [GiphyAssetRequest]() + private var isDownloading = false + + // The success and failure handlers are always called on main queue. + // The success and failure handlers may be called synchronously on cache hit. + public func downloadAssetAsync(rendition: GiphyRendition, + success:@escaping ((GiphyAsset) -> Void), + failure:@escaping (() -> Void)) -> GiphyAssetRequest? { + AssertIsOnMainThread() + + if let asset = assetMap[rendition.url] { + success(asset) + return nil + } + + let assetRequest = GiphyAssetRequest(rendition:rendition, + success : { asset in + DispatchQueue.main.async { + self.assetMap[rendition.url] = asset + success(asset) + self.isDownloading = false + self.downloadIfNecessary() + } + }, + failure : { + DispatchQueue.main.async { + failure() + self.isDownloading = false + self.downloadIfNecessary() + } + }) + assetRequestQueue.append(assetRequest) + downloadIfNecessary() + return assetRequest + } + + private func downloadIfNecessary() { + AssertIsOnMainThread() + + DispatchQueue.main.async { + guard !self.isDownloading else { + return + } + guard self.assetRequestQueue.count > 0 else { + return + } + guard let assetRequest = self.assetRequestQueue.first else { + owsFail("\(GiphyAsset.TAG) could not pop asset requests") + return + } + self.assetRequestQueue.removeFirst() + guard !assetRequest.wasCancelled else { + DispatchQueue.main.async { + self.downloadIfNecessary() + } + return + } + self.isDownloading = true + + if let asset = self.assetMap[assetRequest.rendition.url] { + // Deferred cache hit, avoids re-downloading assets already in the + // asset cache. + assetRequest.success(asset) + return + } + + guard let downloadSession = self.giphyDownloadSession() else { + Logger.error("\(GifDownloader.TAG) Couldn't create session manager.") + assetRequest.failure() + return + } + + let task = downloadSession.downloadTask(with:assetRequest.rendition.url as URL) + task.assetRequest = assetRequest + task.resume() + } + } + + // MARK: URLSessionDataDelegate + + @nonobjc + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + + completionHandler(.allow) + } + + // MARK: URLSessionTaskDelegate + + public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let assetRequest = task.assetRequest + guard !assetRequest.wasCancelled else { + task.cancel() + return + } + if let error = error { + Logger.error("\(GifDownloader.TAG) download failed with error: \(error)") + assetRequest.failure() + return + } + guard let httpResponse = task.response as? HTTPURLResponse else { + Logger.error("\(GifDownloader.TAG) missing or unexpected response: \(task.response)") + assetRequest.failure() + return + } + let statusCode = httpResponse.statusCode + guard statusCode >= 200 && statusCode < 400 else { + Logger.error("\(GifDownloader.TAG) response has invalid status code: \(statusCode)") + assetRequest.failure() + return + } + guard let assetFilePath = assetRequest.assetFilePath else { + Logger.error("\(GifDownloader.TAG) task is missing asset file") + assetRequest.failure() + return + } + Logger.verbose("\(GifDownloader.TAG) download succeeded: \(assetRequest.rendition.url)") + let asset = GiphyAsset(rendition: assetRequest.rendition, filePath : assetFilePath) + assetRequest.success(asset) + } + + // MARK: URLSessionDownloadDelegate + + private func fileExtension(forFormat format: GiphyFormat) -> String { + switch format { + case .gif: + return "gif" + case .webp: + return "webp" + case .mp4: + return "mp4" + } + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + let assetRequest = downloadTask.assetRequest + guard !assetRequest.wasCancelled else { + downloadTask.cancel() + return + } + + let dirPath = NSTemporaryDirectory() + let fileExtension = self.fileExtension(forFormat:assetRequest.rendition.format) + let fileName = (NSUUID().uuidString as NSString).appendingPathExtension(fileExtension)! + let filePath = (dirPath as NSString).appendingPathComponent(fileName) + + do { + try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath:filePath)) + assetRequest.assetFilePath = filePath + } catch let error as NSError { + owsFail("\(GiphyAsset.TAG) file move failed from: \(location), to: \(filePath), \(error)") + } + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + let assetRequest = downloadTask.assetRequest + guard !assetRequest.wasCancelled else { + downloadTask.cancel() + return + } + } + + public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { + let assetRequest = downloadTask.assetRequest + guard !assetRequest.wasCancelled else { + downloadTask.cancel() + return + } + } +} diff --git a/Signal/src/network/GifManager.swift b/Signal/src/network/GifManager.swift index b13d26adc..416d7a467 100644 --- a/Signal/src/network/GifManager.swift +++ b/Signal/src/network/GifManager.swift @@ -61,6 +61,10 @@ enum GiphyFormat { else { continue } + guard !rendition.name.hasSuffix("_downsampled") + else { + continue + } guard rendition.width >= kMinDimension && rendition.width <= kMaxDimension && rendition.height >= kMinDimension && @@ -83,68 +87,7 @@ enum GiphyFormat { } } -@objc class GiphyAssetRequest: NSObject { - static let TAG = "[GiphyAssetRequest]" - - let rendition: GiphyRendition - let success: ((GiphyAsset) -> Void) - let failure: (() -> Void) - var wasCancelled = false - var assetFilePath: String? - - init(rendition: GiphyRendition, - success:@escaping ((GiphyAsset) -> Void), - failure:@escaping (() -> Void) - ) { - self.rendition = rendition - self.success = success - self.failure = failure - } - - public func cancel() { - wasCancelled = true - } -} - -@objc class GiphyAsset: NSObject { - static let TAG = "[GiphyAsset]" - - let rendition: GiphyRendition - let filePath: String - - init(rendition: GiphyRendition, - filePath: String) { - self.rendition = rendition - self.filePath = filePath - } - - deinit { - let filePathCopy = filePath - DispatchQueue.global().async { - do { - let fileManager = FileManager.default - try fileManager.removeItem(atPath:filePathCopy) - } catch let error as NSError { - owsFail("\(GiphyAsset.TAG) file cleanup failed: \(filePathCopy), \(error)") - } - } - } -} - -private var URLSessionTask_GiphyAssetRequest: UInt8 = 0 - -extension URLSessionTask { - var assetRequest: GiphyAssetRequest { - get { - return objc_getAssociatedObject(self, &URLSessionTask_GiphyAssetRequest) as! GiphyAssetRequest - } - set { - objc_setAssociatedObject(self, &URLSessionTask_GiphyAssetRequest, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } -} - -@objc class GifManager: NSObject, URLSessionTaskDelegate, URLSessionDownloadDelegate { +@objc class GifManager: NSObject { // MARK: - Properties @@ -152,8 +95,6 @@ extension URLSessionTask { static let sharedInstance = GifManager() - private let operationQueue = OperationQueue() - // Force usage as a singleton override private init() {} @@ -185,33 +126,6 @@ extension URLSessionTask { return sessionManager } - private func giphyDownloadSession() -> URLSession? { -// guard let baseUrl = NSURL(string:kGiphyBaseURL) else { -// Logger.error("\(GifManager.TAG) Invalid base URL.") -// return nil -// } - // TODO: Is this right? - let configuration = URLSessionConfiguration.ephemeral - // TODO: Is this right? - configuration.connectionProxyDictionary = [ - kCFProxyHostNameKey as String: "giphy-proxy-production.whispersystems.org", - kCFProxyPortNumberKey as String: "80", - kCFProxyTypeKey as String: kCFProxyTypeHTTPS - ] - configuration.urlCache = nil - configuration.requestCachePolicy = .reloadIgnoringCacheData - let session = URLSession(configuration:configuration, delegate:self, delegateQueue:operationQueue) - return session -// NSURLSession * session = [NSURLSession sessionWithConfiguration:configuration]; -// -// let sessionManager = AFHTTPSessionManager(baseURL:baseUrl as URL, -// sessionConfiguration:sessionConf) -// sessionManager.requestSerializer = AFJSONRequestSerializer() -// sessionManager.responseSerializer = AFJSONResponseSerializer() -// -// return sessionManager - } - // TODO: public func test() { search(query:"monkey", @@ -288,6 +202,7 @@ extension URLSessionTask { return result } + // Giphy API results are often incomplete or malformed, so we need to be defensive. private func parseGiphyImage(imageDict: [String:Any]) -> GiphyImageInfo? { guard let giphyId = imageDict["id"] as? String else { Logger.warn("\(GifManager.TAG) Image dict missing id.") @@ -338,6 +253,7 @@ extension URLSessionTask { return nil } + // Giphy API results are often incomplete or malformed, so we need to be defensive. private func parseGiphyRendition(renditionName: String, renditionDict: [String:Any]) -> GiphyRendition? { guard let width = parsePositiveUInt(dict:renditionDict, key:"width", typeName:"rendition") else { @@ -381,8 +297,6 @@ extension URLSessionTask { ) } - // Giphy API results are often incompl - // // { // height = 65; // mp4 = "https://media3.giphy.com/media/42YlR8u9gV5Cw/100w.mp4"; @@ -412,178 +326,4 @@ extension URLSessionTask { } return parsedValue } - - // MARK: Rendition Download - - // TODO: Use a proper cache. - private var assetMap = [NSURL: GiphyAsset]() - // TODO: We could use a proper queue. - private var assetRequestQueue = [GiphyAssetRequest]() - private var isDownloading = false - - // The success and failure handlers are always called on main queue. - // The success and failure handlers may be called synchronously on cache hit. - public func downloadAssetAsync(rendition: GiphyRendition, - success:@escaping ((GiphyAsset) -> Void), - failure:@escaping (() -> Void)) -> GiphyAssetRequest? { - AssertIsOnMainThread() - - if let asset = assetMap[rendition.url] { - success(asset) - return nil - } - - let assetRequest = GiphyAssetRequest(rendition:rendition, - success : { asset in - DispatchQueue.main.async { - self.assetMap[rendition.url] = asset - success(asset) - self.isDownloading = false - self.downloadIfNecessary() - } - }, - failure : { - DispatchQueue.main.async { - failure() - self.isDownloading = false - self.downloadIfNecessary() - } - }) - assetRequestQueue.append(assetRequest) - downloadIfNecessary() - return assetRequest - } - - private func downloadIfNecessary() { - AssertIsOnMainThread() - - DispatchQueue.main.async { - guard !self.isDownloading else { - return - } - guard self.assetRequestQueue.count > 0 else { - return - } - guard let assetRequest = self.assetRequestQueue.first else { - owsFail("\(GiphyAsset.TAG) could not pop asset requests") - return - } - self.assetRequestQueue.removeFirst() - guard !assetRequest.wasCancelled else { - DispatchQueue.main.async { - self.downloadIfNecessary() - } - return - } - self.isDownloading = true - - if let asset = self.assetMap[assetRequest.rendition.url] { - // Deferred cache hit, avoids re-downloading assets already in the - // asset cache. - assetRequest.success(asset) - return - } - - guard let downloadSession = self.giphyDownloadSession() else { - Logger.error("\(GifManager.TAG) Couldn't create session manager.") - assetRequest.failure() - return - } - - let task = downloadSession.downloadTask(with:assetRequest.rendition.url as URL) - task.assetRequest = assetRequest - task.resume() - } - } - - // MARK: URLSessionDataDelegate - - @nonobjc - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - - completionHandler(.allow) - } - - // MARK: URLSessionTaskDelegate - - public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - let assetRequest = task.assetRequest - guard !assetRequest.wasCancelled else { - task.cancel() - return - } - if let error = error { - Logger.error("\(GifManager.TAG) download failed with error: \(error)") - assetRequest.failure() - return - } - guard let httpResponse = task.response as? HTTPURLResponse else { - Logger.error("\(GifManager.TAG) missing or unexpected response: \(task.response)") - assetRequest.failure() - return - } - let statusCode = httpResponse.statusCode - guard statusCode >= 200 && statusCode < 400 else { - Logger.error("\(GifManager.TAG) response has invalid status code: \(statusCode)") - assetRequest.failure() - return - } - guard let assetFilePath = assetRequest.assetFilePath else { - Logger.error("\(GifManager.TAG) task is missing asset file") - assetRequest.failure() - return - } - Logger.verbose("\(GifManager.TAG) download succeeded: \(assetRequest.rendition.url)") - let asset = GiphyAsset(rendition: assetRequest.rendition, filePath : assetFilePath) - assetRequest.success(asset) - } - - // MARK: URLSessionDownloadDelegate - - private func fileExtension(forFormat format: GiphyFormat) -> String { - switch format { - case .gif: - return "gif" - case .webp: - return "webp" - case .mp4: - return "mp4" - } - } - - public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - let assetRequest = downloadTask.assetRequest - guard !assetRequest.wasCancelled else { - downloadTask.cancel() - return - } - - let dirPath = NSTemporaryDirectory() - let fileExtension = self.fileExtension(forFormat:assetRequest.rendition.format) - let fileName = (NSUUID().uuidString as NSString).appendingPathExtension(fileExtension)! - let filePath = (dirPath as NSString).appendingPathComponent(fileName) - - do { - try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath:filePath)) - assetRequest.assetFilePath = filePath - } catch let error as NSError { - owsFail("\(GiphyAsset.TAG) file move failed from: \(location), to: \(filePath), \(error)") - } - } - - public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - let assetRequest = downloadTask.assetRequest - guard !assetRequest.wasCancelled else { - downloadTask.cancel() - return - } - } - - public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { - let assetRequest = downloadTask.assetRequest - guard !assetRequest.wasCancelled else { - downloadTask.cancel() - return - } - } }