mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			518 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			518 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import Foundation
 | |
| import Combine
 | |
| import CoreServices
 | |
| import SignalUtilitiesKit
 | |
| import SessionUtilitiesKit
 | |
| import SignalCoreKit
 | |
| 
 | |
| // There's no UTI type for webp!
 | |
| enum GiphyFormat {
 | |
|     case gif, mp4, jpg
 | |
| }
 | |
| 
 | |
| enum GiphyError: Error {
 | |
|     case assertionError(description: String)
 | |
|     case fetchFailure
 | |
| }
 | |
| 
 | |
| extension GiphyError: LocalizedError {
 | |
|     public var errorDescription: String? {
 | |
|         switch self {
 | |
|             case .assertionError: return "GIF_PICKER_ERROR_GENERIC".localized()
 | |
|             case .fetchFailure: return "GIF_PICKER_ERROR_FETCH_FAILURE".localized()
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // Represents a "rendition" of a GIF.
 | |
| // Giphy offers a plethora of renditions for each image.
 | |
| // They vary in content size (i.e. width,  height), 
 | |
| // format (.jpg, .gif, .mp4, webp, etc.),
 | |
| // quality, etc.
 | |
| class GiphyRendition: ProxiedContentAssetDescription {
 | |
|     let format: GiphyFormat
 | |
|     let name: String
 | |
|     let width: UInt
 | |
|     let height: UInt
 | |
|     let fileSize: UInt
 | |
| 
 | |
|     init?(format: GiphyFormat,
 | |
|          name: String,
 | |
|          width: UInt,
 | |
|          height: UInt,
 | |
|          fileSize: UInt,
 | |
|          url: NSURL) {
 | |
|         self.format = format
 | |
|         self.name = name
 | |
|         self.width = width
 | |
|         self.height = height
 | |
|         self.fileSize = fileSize
 | |
| 
 | |
|         let fileExtension = GiphyRendition.fileExtension(forFormat: format)
 | |
|         super.init(url: url, fileExtension: fileExtension)
 | |
|     }
 | |
| 
 | |
|     private class func fileExtension(forFormat format: GiphyFormat) -> String {
 | |
|         switch format {
 | |
|         case .gif:
 | |
|             return "gif"
 | |
|         case .mp4:
 | |
|             return "mp4"
 | |
|         case .jpg:
 | |
|             return "jpg"
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public var utiType: String {
 | |
|         switch format {
 | |
|         case .gif:
 | |
|             return kUTTypeGIF as String
 | |
|         case .mp4:
 | |
|             return kUTTypeMPEG4 as String
 | |
|         case .jpg:
 | |
|             return kUTTypeJPEG as String
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public var isStill: Bool {
 | |
|         return name.hasSuffix("_still")
 | |
|     }
 | |
| 
 | |
|     public var isDownsampled: Bool {
 | |
|         return name.hasSuffix("_downsampled")
 | |
|     }
 | |
| 
 | |
|     public func log() {
 | |
|         Logger.verbose("\t \(format), \(name), \(width), \(height), \(fileSize)")
 | |
|     }
 | |
| }
 | |
| 
 | |
| // Represents a single Giphy image.
 | |
| class GiphyImageInfo: NSObject {
 | |
|     let giphyId: String
 | |
|     let renditions: [GiphyRendition]
 | |
|     // We special-case the "original" rendition because it is the 
 | |
|     // source of truth for the aspect ratio of the image.
 | |
|     let originalRendition: GiphyRendition
 | |
| 
 | |
|     init(giphyId: String,
 | |
|          renditions: [GiphyRendition],
 | |
|          originalRendition: GiphyRendition) {
 | |
|         self.giphyId = giphyId
 | |
|         self.renditions = renditions
 | |
|         self.originalRendition = originalRendition
 | |
|     }
 | |
| 
 | |
|     // TODO: We may need to tweak these constants.
 | |
|     let kMaxDimension = UInt(618)
 | |
|     let kMinPreviewDimension = UInt(60)
 | |
|     let kMinSendingDimension = UInt(101)
 | |
|     let kPreferedPreviewFileSize = UInt(256 * 1024)
 | |
|     let kPreferedSendingFileSize = UInt(3 * 1024 * 1024)
 | |
| 
 | |
|     private enum PickingStrategy {
 | |
|         case smallerIsBetter, largerIsBetter
 | |
|     }
 | |
| 
 | |
|     public func log() {
 | |
|         Logger.verbose("giphyId: \(giphyId), \(renditions.count)")
 | |
|         for rendition in renditions {
 | |
|             rendition.log()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public func pickStillRendition() -> GiphyRendition? {
 | |
|         // Stills are just temporary placeholders, so use the smallest still possible.
 | |
|         return pickRendition(renditionType: .stillPreview, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize)
 | |
|     }
 | |
| 
 | |
|     public func pickPreviewRendition() -> GiphyRendition? {
 | |
|         // Try to pick a small file...
 | |
|         if let rendition = pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .largerIsBetter, maxFileSize: kPreferedPreviewFileSize) {
 | |
|             return rendition
 | |
|         }
 | |
|         // ...but gradually relax the file restriction...
 | |
|         if let rendition = pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize * 2) {
 | |
|             return rendition
 | |
|         }
 | |
|         // ...and relax even more until we find an animated rendition.
 | |
|         return pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize * 3)
 | |
|     }
 | |
| 
 | |
|     public func pickSendingRendition() -> GiphyRendition? {
 | |
|         // Try to pick a small file...
 | |
|         if let rendition = pickRendition(renditionType: .animatedHighQuality, pickingStrategy: .largerIsBetter, maxFileSize: kPreferedSendingFileSize) {
 | |
|             return rendition
 | |
|         }
 | |
|         // ...but gradually relax the file restriction...
 | |
|         if let rendition = pickRendition(renditionType: .animatedHighQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedSendingFileSize * 2) {
 | |
|             return rendition
 | |
|         }
 | |
|         // ...and relax even more until we find an animated rendition.
 | |
|         return pickRendition(renditionType: .animatedHighQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedSendingFileSize * 3)
 | |
|     }
 | |
| 
 | |
|     enum RenditionType {
 | |
|         case stillPreview, animatedLowQuality, animatedHighQuality
 | |
|     }
 | |
| 
 | |
|     // Picking a rendition must be done very carefully.
 | |
|     //
 | |
|     // * We want to avoid incomplete renditions.
 | |
|     // * We want to pick a rendition of "just good enough" quality.
 | |
|     private func pickRendition(renditionType: RenditionType, pickingStrategy: PickingStrategy, maxFileSize: UInt) -> GiphyRendition? {
 | |
|         var bestRendition: GiphyRendition?
 | |
| 
 | |
|         for rendition in renditions {
 | |
|             switch renditionType {
 | |
|             case .stillPreview:
 | |
|                 // Accept GIF or JPEG stills.  In practice we'll
 | |
|                 // usually select a JPEG since they'll be smaller.
 | |
|                 guard [.gif, .jpg].contains(rendition.format) else {
 | |
|                     continue
 | |
|                 }
 | |
|                 // Only consider still renditions.
 | |
|                 guard rendition.isStill else {
 | |
|                         continue
 | |
|                 }
 | |
|                 // Accept still renditions without a valid file size.  Note that fileSize
 | |
|                 // will be zero for renditions without a valid file size, so they will pass
 | |
|                 // the maxFileSize test.
 | |
|                 //
 | |
|                 // Don't worry about max content size; still images are tiny in comparison
 | |
|                 // with animated renditions.
 | |
|                 guard rendition.width >= kMinPreviewDimension &&
 | |
|                     rendition.height >= kMinPreviewDimension &&
 | |
|                     rendition.fileSize <= maxFileSize
 | |
|                     else {
 | |
|                         continue
 | |
|                 }
 | |
|             case .animatedLowQuality:
 | |
|                 // Only use GIFs for animated renditions.
 | |
|                 guard rendition.format == .gif else {
 | |
|                     continue
 | |
|                 }
 | |
|                 // Ignore stills.
 | |
|                 guard !rendition.isStill else {
 | |
|                         continue
 | |
|                 }
 | |
|                 // Ignore "downsampled" renditions which skip frames, etc.
 | |
|                 guard !rendition.isDownsampled else {
 | |
|                         continue
 | |
|                 }
 | |
|                 guard rendition.width >= kMinPreviewDimension &&
 | |
|                     rendition.width <= kMaxDimension &&
 | |
|                     rendition.height >= kMinPreviewDimension &&
 | |
|                     rendition.height <= kMaxDimension &&
 | |
|                     rendition.fileSize > 0 &&
 | |
|                     rendition.fileSize <= maxFileSize
 | |
|                     else {
 | |
|                         continue
 | |
|                 }
 | |
|             case .animatedHighQuality:
 | |
|                 // Only use GIFs for animated renditions.
 | |
|                 guard rendition.format == .gif else {
 | |
|                     continue
 | |
|                 }
 | |
|                 // Ignore stills.
 | |
|                 guard !rendition.isStill else {
 | |
|                     continue
 | |
|                 }
 | |
|                 // Ignore "downsampled" renditions which skip frames, etc.
 | |
|                 guard !rendition.isDownsampled else {
 | |
|                     continue
 | |
|                 }
 | |
|                 guard rendition.width >= kMinSendingDimension &&
 | |
|                     rendition.width <= kMaxDimension &&
 | |
|                     rendition.height >= kMinSendingDimension &&
 | |
|                     rendition.height <= kMaxDimension &&
 | |
|                     rendition.fileSize > 0 &&
 | |
|                     rendition.fileSize <= maxFileSize
 | |
|                     else {
 | |
|                         continue
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             if let currentBestRendition = bestRendition {
 | |
|                 if rendition.width == currentBestRendition.width &&
 | |
|                     rendition.fileSize > 0 &&
 | |
|                     currentBestRendition.fileSize > 0 &&
 | |
|                     rendition.fileSize < currentBestRendition.fileSize {
 | |
|                     // If two renditions have the same content size, prefer
 | |
|                     // the rendition with the smaller file size, e.g.
 | |
|                     // prefer JPEG over GIF for stills.
 | |
|                     bestRendition = rendition
 | |
|                 } else if pickingStrategy == .smallerIsBetter {
 | |
|                     // "Smaller is better"
 | |
|                     if rendition.width < currentBestRendition.width {
 | |
|                         bestRendition = rendition
 | |
|                     }
 | |
|                 } else {
 | |
|                     // "Larger is better"
 | |
|                     if rendition.width > currentBestRendition.width {
 | |
|                         bestRendition = rendition
 | |
|                     }
 | |
|                 }
 | |
|             } else {
 | |
|                 bestRendition = rendition
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return bestRendition
 | |
|     }
 | |
| }
 | |
| 
 | |
| enum GiphyAPI {
 | |
|     private static let kGiphyBaseURL = "https://api.giphy.com"
 | |
|     private static let urlSession: URLSession = {
 | |
|         let configuration: URLSessionConfiguration = ContentProxy.sessionConfiguration()
 | |
|         
 | |
|         // Don't use any caching to protect privacy of these requests.
 | |
|         configuration.urlCache = nil
 | |
|         configuration.requestCachePolicy = .reloadIgnoringCacheData
 | |
|         
 | |
|         return URLSession(configuration: configuration)
 | |
|     }()
 | |
| 
 | |
|     // MARK: - Search
 | |
|     
 | |
|     // This is the Signal iOS API key.
 | |
|     private static let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc"
 | |
|     private static let kGiphyPageSize = 20
 | |
|     
 | |
|     public static func trending() -> AnyPublisher<[GiphyImageInfo], Error> {
 | |
|         let urlString = "/v1/gifs/trending?api_key=\(kGiphyApiKey)&limit=\(kGiphyPageSize)"
 | |
|         
 | |
|         guard let url: URL = URL(string: "\(kGiphyBaseURL)\(urlString)") else {
 | |
|             return Fail(error: HTTPError.invalidURL)
 | |
|                 .eraseToAnyPublisher()
 | |
|         }
 | |
|         
 | |
|         return urlSession
 | |
|             .dataTaskPublisher(for: url)
 | |
|             .mapError { urlError in
 | |
|                 Logger.error("search request failed: \(urlError)")
 | |
|                 
 | |
|                 // URLError codes are negative values
 | |
|                 return HTTPError.generic
 | |
|             }
 | |
|             .map { data, _ in
 | |
|                 Logger.debug("search request succeeded")
 | |
|                 
 | |
|                 guard let imageInfos = self.parseGiphyImages(responseData: data) else {
 | |
|                     Logger.error("unable to parse trending images")
 | |
|                     return []
 | |
|                 }
 | |
|                 
 | |
|                 return imageInfos
 | |
|             }
 | |
|             .eraseToAnyPublisher()
 | |
|     }
 | |
| 
 | |
|     public static func search(query: String) -> AnyPublisher<[GiphyImageInfo], Error> {
 | |
|         let kGiphyPageOffset = 0
 | |
|         
 | |
|         guard
 | |
|             let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
 | |
|             let url: URL = URL(
 | |
|                 string: [
 | |
|                     kGiphyBaseURL,
 | |
|                     "/v1/gifs/search?api_key=\(kGiphyApiKey)",
 | |
|                     "&offset=\(kGiphyPageOffset)",
 | |
|                     "&limit=\(kGiphyPageSize)",
 | |
|                     "&q=\(queryEncoded)"
 | |
|                 ].joined()
 | |
|             )
 | |
|         else {
 | |
|             return Fail(error: HTTPError.invalidURL)
 | |
|                 .eraseToAnyPublisher()
 | |
|         }
 | |
|         
 | |
|         var request: URLRequest = URLRequest(url: url)
 | |
|         
 | |
|         guard ContentProxy.configureProxiedRequest(request: &request) else {
 | |
|             owsFailDebug("Could not configure query: \(query).")
 | |
|             return Fail(error: HTTPError.generic)
 | |
|                 .eraseToAnyPublisher()
 | |
|         }
 | |
|         
 | |
|         return urlSession
 | |
|             .dataTaskPublisher(for: request)
 | |
|             .mapError { urlError in
 | |
|                 Logger.error("search request failed: \(urlError)")
 | |
|                 
 | |
|                 // URLError codes are negative values
 | |
|                 return HTTPError.generic
 | |
|             }
 | |
|             .tryMap { data, _ -> [GiphyImageInfo] in
 | |
|                 Logger.debug("search request succeeded")
 | |
|                 
 | |
|                 guard let imageInfos = self.parseGiphyImages(responseData: data) else {
 | |
|                     throw HTTPError.invalidResponse
 | |
|                 }
 | |
|                 
 | |
|                 return imageInfos
 | |
|             }
 | |
|             .eraseToAnyPublisher()
 | |
|     }
 | |
| 
 | |
|     // MARK: - Parse API Responses
 | |
| 
 | |
|     private static func parseGiphyImages(responseData: Data?) -> [GiphyImageInfo]? {
 | |
|         guard let responseData: Data = responseData else {
 | |
|             Logger.error("Missing response.")
 | |
|             return nil
 | |
|         }
 | |
|         guard let responseDict: [String: Any] = try? JSONSerialization
 | |
|             .jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? [String: Any] else {
 | |
|             Logger.error("Invalid response.")
 | |
|             return nil
 | |
|         }
 | |
|         guard let imageDicts = responseDict["data"] as? [[String: Any]] else {
 | |
|             Logger.error("Invalid response data.")
 | |
|             return nil
 | |
|         }
 | |
|         return imageDicts.compactMap { imageDict in
 | |
|             return parseGiphyImage(imageDict: imageDict)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // Giphy API results are often incomplete or malformed, so we need to be defensive.
 | |
|     private static func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? {
 | |
|         guard let giphyId = imageDict["id"] as? String else {
 | |
|             Logger.warn("Image dict missing id.")
 | |
|             return nil
 | |
|         }
 | |
|         guard giphyId.count > 0 else {
 | |
|             Logger.warn("Image dict has invalid id.")
 | |
|             return nil
 | |
|         }
 | |
|         guard let renditionDicts = imageDict["images"] as? [String: Any] else {
 | |
|             Logger.warn("Image dict missing renditions.")
 | |
|             return nil
 | |
|         }
 | |
|         var renditions = [GiphyRendition]()
 | |
|         for (renditionName, renditionDict) in renditionDicts {
 | |
|             guard let renditionDict = renditionDict as? [String: Any] else {
 | |
|                 Logger.warn("Invalid rendition dict.")
 | |
|                 continue
 | |
|             }
 | |
|             guard let rendition = parseGiphyRendition(renditionName: renditionName,
 | |
|                                                       renditionDict: renditionDict) else {
 | |
|                                                         continue
 | |
|             }
 | |
|             renditions.append(rendition)
 | |
|         }
 | |
|         guard renditions.count > 0 else {
 | |
|             Logger.warn("Image has no valid renditions.")
 | |
|             return nil
 | |
|         }
 | |
| 
 | |
|         guard let originalRendition = findOriginalRendition(renditions: renditions) else {
 | |
|             Logger.warn("Image has no original rendition.")
 | |
|             return nil
 | |
|         }
 | |
| 
 | |
|         return GiphyImageInfo(
 | |
|             giphyId: giphyId,
 | |
|             renditions: renditions,
 | |
|             originalRendition: originalRendition
 | |
|         )
 | |
|     }
 | |
| 
 | |
|     private static func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? {
 | |
|         for rendition in renditions where rendition.name == "original" {
 | |
|             return rendition
 | |
|         }
 | |
|         return nil
 | |
|     }
 | |
| 
 | |
|     // Giphy API results are often incomplete or malformed, so we need to be defensive.
 | |
|     //
 | |
|     // We should discard renditions which are missing or have invalid properties.
 | |
|     private static func parseGiphyRendition(
 | |
|         renditionName: String,
 | |
|         renditionDict: [String: Any]
 | |
|     ) -> GiphyRendition? {
 | |
|         guard let width = parsePositiveUInt(dict: renditionDict, key: "width", typeName: "rendition") else {
 | |
|             return nil
 | |
|         }
 | |
|         guard let height = parsePositiveUInt(dict: renditionDict, key: "height", typeName: "rendition") else {
 | |
|             return nil
 | |
|         }
 | |
|         // Be lenient when parsing file sizes - we don't require them for stills.
 | |
|         let fileSize = parseLenientUInt(dict: renditionDict, key: "size")
 | |
|         guard let urlString = renditionDict["url"] as? String else {
 | |
|             return nil
 | |
|         }
 | |
|         guard urlString.count > 0 else {
 | |
|             Logger.warn("Rendition has invalid url.")
 | |
|             return nil
 | |
|         }
 | |
|         guard let url = NSURL(string: urlString) else {
 | |
|             Logger.warn("Rendition url could not be parsed.")
 | |
|             return nil
 | |
|         }
 | |
|         guard let fileExtension = url.pathExtension?.lowercased() else {
 | |
|             Logger.warn("Rendition url missing file extension.")
 | |
|             return nil
 | |
|         }
 | |
|         var format = GiphyFormat.gif
 | |
|         if fileExtension == "gif" {
 | |
|             format = .gif
 | |
|         } else if fileExtension == "jpg" {
 | |
|             format = .jpg
 | |
|         } else if fileExtension == "mp4" {
 | |
|             format = .mp4
 | |
|         } else if fileExtension == "webp" {
 | |
|             return nil
 | |
|         } else {
 | |
|             Logger.warn("Invalid file extension: \(fileExtension).")
 | |
|             return nil
 | |
|         }
 | |
| 
 | |
|         return GiphyRendition(
 | |
|             format: format,
 | |
|             name: renditionName,
 | |
|             width: width,
 | |
|             height: height,
 | |
|             fileSize: fileSize,
 | |
|             url: url
 | |
|         )
 | |
|     }
 | |
| 
 | |
|     private static func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? {
 | |
|         guard let value = dict[key] else {
 | |
|             return nil
 | |
|         }
 | |
|         guard let stringValue = value as? String else {
 | |
|             return nil
 | |
|         }
 | |
|         guard let parsedValue = UInt(stringValue) else {
 | |
|             return nil
 | |
|         }
 | |
|         guard parsedValue > 0 else {
 | |
|             Logger.verbose("\(typeName) has non-positive \(key): \(parsedValue).")
 | |
|             return nil
 | |
|         }
 | |
|         return parsedValue
 | |
|     }
 | |
| 
 | |
|     private static func parseLenientUInt(dict: [String: Any], key: String) -> UInt {
 | |
|         let defaultValue = UInt(0)
 | |
| 
 | |
|         guard let value = dict[key] else {
 | |
|             return defaultValue
 | |
|         }
 | |
|         guard let stringValue = value as? String else {
 | |
|             return defaultValue
 | |
|         }
 | |
|         guard let parsedValue = UInt(stringValue) else {
 | |
|             return defaultValue
 | |
|         }
 | |
|         return parsedValue
 | |
|     }
 | |
| }
 |