session-ios/Session/Media Viewing & Editing/GIFs/GiphyAPI.swift

523 lines
19 KiB
Swift
Raw Normal View History

2017-09-24 05:37:30 +02:00
//
2019-01-15 21:52:08 +01:00
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
2017-09-24 05:37:30 +02:00
//
2020-12-17 05:20:43 +01:00
import AFNetworking
2017-09-24 05:37:30 +02:00
import Foundation
import PromiseKit
2020-11-12 03:20:28 +01:00
import CoreServices
import SignalUtilitiesKit
import SessionUtilitiesKit
2017-09-24 05:37:30 +02:00
// There's no UTI type for webp!
2017-09-24 06:44:35 +02:00
enum GiphyFormat {
case gif, mp4, jpg
2017-09-24 06:44:35 +02:00
}
enum GiphyError: Error {
case assertionError(description: String)
case fetchFailure
}
extension GiphyError: LocalizedError {
public var errorDescription: String? {
switch self {
case .assertionError:
return NSLocalizedString("GIF_PICKER_ERROR_GENERIC", comment: "Generic error displayed when picking a GIF")
case .fetchFailure:
return NSLocalizedString("GIF_PICKER_ERROR_FETCH_FAILURE", comment: "Error displayed when there is a failure fetching a GIF from the remote service.")
}
}
}
2017-10-01 00:22:08 +02:00
// 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.
2019-01-23 20:03:21 +01:00
@objc class GiphyRendition: ProxiedContentAssetDescription {
2017-09-24 06:44:35 +02:00
let format: GiphyFormat
let name: String
let width: UInt
let height: UInt
let fileSize: UInt
2019-01-23 20:03:21 +01:00
init?(format: GiphyFormat,
2017-09-25 02:48:30 +02:00
name: String,
width: UInt,
height: UInt,
fileSize: UInt,
url: NSURL) {
2017-09-24 06:44:35 +02:00
self.format = format
self.name = name
self.width = width
self.height = height
self.fileSize = fileSize
2019-01-23 20:03:21 +01:00
let fileExtension = GiphyRendition.fileExtension(forFormat: format)
super.init(url: url, fileExtension: fileExtension)
2017-09-24 06:44:35 +02:00
}
2019-01-23 20:03:21 +01:00
private class func fileExtension(forFormat format: GiphyFormat) -> String {
switch format {
case .gif:
return "gif"
case .mp4:
return "mp4"
case .jpg:
return "jpg"
}
}
2017-10-02 21:24:57 +02:00
public var utiType: String {
switch format {
case .gif:
return kUTTypeGIF as String
case .mp4:
return kUTTypeMPEG4 as String
case .jpg:
return kUTTypeJPEG as String
}
}
2017-10-02 21:24:57 +02:00
public var isStill: Bool {
return name.hasSuffix("_still")
}
public var isDownsampled: Bool {
return name.hasSuffix("_downsampled")
}
public func log() {
2018-08-24 18:40:16 +02:00
Logger.verbose("\t \(format), \(name), \(width), \(height), \(fileSize)")
}
2017-09-24 06:44:35 +02:00
}
2017-10-01 00:22:08 +02:00
// Represents a single Giphy image.
2017-09-24 06:44:35 +02:00
@objc class GiphyImageInfo: NSObject {
let giphyId: String
let renditions: [GiphyRendition]
2017-10-01 00:22:08 +02:00
// We special-case the "original" rendition because it is the
// source of truth for the aspect ratio of the image.
2017-09-29 00:15:18 +02:00
let originalRendition: GiphyRendition
2017-09-24 06:44:35 +02:00
init(giphyId: String,
2017-09-29 00:15:18 +02:00
renditions: [GiphyRendition],
originalRendition: GiphyRendition) {
2017-09-24 06:44:35 +02:00
self.giphyId = giphyId
self.renditions = renditions
2017-09-29 00:15:18 +02:00
self.originalRendition = originalRendition
2017-09-24 06:44:35 +02:00
}
2017-09-28 20:09:11 +02:00
2017-10-01 20:41:40 +02:00
// TODO: We may need to tweak these constants.
2017-09-28 20:09:11 +02:00
let kMaxDimension = UInt(618)
let kMinPreviewDimension = UInt(60)
let kMinSendingDimension = UInt(101)
let kPreferedPreviewFileSize = UInt(256 * 1024)
let kPreferedSendingFileSize = UInt(3 * 1024 * 1024)
2017-09-28 20:09:11 +02:00
2017-09-30 04:05:01 +02:00
private enum PickingStrategy {
case smallerIsBetter, largerIsBetter
}
public func log() {
2018-08-24 18:40:16 +02:00
Logger.verbose("giphyId: \(giphyId), \(renditions.count)")
for rendition in renditions {
rendition.log()
}
}
public func pickStillRendition() -> GiphyRendition? {
2017-10-01 00:22:08 +02:00
// Stills are just temporary placeholders, so use the smallest still possible.
return pickRendition(renditionType: .stillPreview, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize)
}
2017-10-20 20:27:36 +02:00
public func pickPreviewRendition() -> GiphyRendition? {
2017-09-30 04:05:01 +02:00
// Try to pick a small file...
if let rendition = pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .largerIsBetter, maxFileSize: kPreferedPreviewFileSize) {
2017-09-30 04:05:01 +02:00
return rendition
}
// ...but gradually relax the file restriction...
if let rendition = pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize * 2) {
2017-09-30 04:05:01 +02:00
return rendition
}
2017-10-01 00:22:08 +02:00
// ...and relax even more until we find an animated rendition.
return pickRendition(renditionType: .animatedLowQuality, pickingStrategy: .smallerIsBetter, maxFileSize: kPreferedPreviewFileSize * 3)
}
2017-10-20 20:27:36 +02:00
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
}
2017-10-01 00:22:08 +02:00
// 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? {
2017-09-28 20:09:11 +02:00
var bestRendition: GiphyRendition?
for rendition in renditions {
switch renditionType {
case .stillPreview:
2017-10-01 00:22:08 +02:00
// 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 {
2017-09-28 20:09:11 +02:00
continue
}
2017-10-01 00:22:08 +02:00
// Only consider still renditions.
2017-10-02 21:24:57 +02:00
guard rendition.isStill else {
continue
}
2017-10-02 21:24:57 +02:00
// 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.
2017-10-01 00:22:08 +02:00
//
// Don't worry about max content size; still images are tiny in comparison
// with animated renditions.
guard rendition.width >= kMinPreviewDimension &&
rendition.height >= kMinPreviewDimension &&
2017-09-30 04:05:01 +02:00
rendition.fileSize <= maxFileSize
else {
continue
}
case .animatedLowQuality:
2017-10-01 00:22:08 +02:00
// Only use GIFs for animated renditions.
guard rendition.format == .gif else {
2017-09-28 20:09:11 +02:00
continue
}
2017-10-01 00:22:08 +02:00
// Ignore stills.
2017-10-02 21:24:57 +02:00
guard !rendition.isStill else {
continue
}
2017-10-01 00:22:08 +02:00
// Ignore "downsampled" renditions which skip frames, etc.
2017-10-02 21:24:57 +02:00
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 &&
2017-09-30 04:05:01 +02:00
rendition.fileSize <= maxFileSize
else {
continue
}
2017-09-28 20:09:11 +02:00
}
if let currentBestRendition = bestRendition {
2017-10-01 00:22:08 +02:00
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 {
2017-10-01 00:22:08 +02:00
// "Larger is better"
if rendition.width > currentBestRendition.width {
bestRendition = rendition
}
2017-09-28 20:09:11 +02:00
}
} else {
bestRendition = rendition
}
}
return bestRendition
}
2017-09-24 06:44:35 +02:00
}
2017-10-01 20:54:39 +02:00
@objc class GiphyAPI: NSObject {
2017-09-24 05:37:30 +02:00
// MARK: - Properties
2017-10-01 20:54:39 +02:00
static let sharedInstance = GiphyAPI()
2017-09-24 05:37:30 +02:00
// Force usage as a singleton
2017-12-07 16:33:27 +01:00
override private init() {
super.init()
SwiftSingletons.register(self)
}
2017-09-24 05:37:30 +02:00
deinit {
NotificationCenter.default.removeObserver(self)
}
private let kGiphyBaseURL = "https://api.giphy.com/"
2017-10-20 22:21:52 +02:00
private func giphyAPISessionManager() -> AFHTTPSessionManager? {
2021-05-06 05:43:31 +02:00
return AFHTTPSessionManager(baseURL: URL(string: kGiphyBaseURL), sessionConfiguration: .ephemeral)
2017-09-24 05:37:30 +02:00
}
2017-09-29 05:30:33 +02:00
// MARK: Search
// This is the Signal iOS API key.
let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc"
2021-05-06 05:43:31 +02:00
let kGiphyPageSize = 20
public func trending() -> Promise<[GiphyImageInfo]> {
guard let sessionManager = giphyAPISessionManager() else {
Logger.error("Couldn't create session manager.")
return Promise.value([])
}
let urlString = "/v1/gifs/trending?api_key=\(kGiphyApiKey)&limit=\(kGiphyPageSize)"
let (promise, resolver) = Promise<[GiphyImageInfo]>.pending()
sessionManager.get(urlString,
parameters: [String: AnyObject](),
2020-11-12 03:20:28 +01:00
headers:nil,
progress: nil,
success: { _, value in
Logger.error("search request succeeded")
if let imageInfos = self.parseGiphyImages(responseJson: value) {
resolver.fulfill(imageInfos)
} else {
Logger.error("unable to parse trending images")
resolver.fulfill([])
}
},
failure: { _, error in
Logger.error("search request failed: \(error)")
resolver.reject(error)
})
return promise
}
2017-09-29 05:30:33 +02:00
2017-10-02 21:24:57 +02:00
public func search(query: String, success: @escaping (([GiphyImageInfo]) -> Void), failure: @escaping ((NSError?) -> Void)) {
2017-09-29 05:30:33 +02:00
guard let sessionManager = giphyAPISessionManager() else {
2018-08-23 16:37:34 +02:00
Logger.error("Couldn't create session manager.")
2017-10-02 21:24:57 +02:00
failure(nil)
2017-09-24 05:37:30 +02:00
return
}
guard NSURL(string: kGiphyBaseURL) != nil else {
2018-08-23 16:37:34 +02:00
Logger.error("Invalid base URL.")
2017-10-02 21:24:57 +02:00
failure(nil)
2017-09-24 05:37:30 +02:00
return
}
let kGiphyPageOffset = 0
guard let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
2018-08-23 16:37:34 +02:00
Logger.error("Could not URL encode query: \(query).")
2017-10-02 21:24:57 +02:00
failure(nil)
2017-09-24 05:37:30 +02:00
return
}
let urlString = "/v1/gifs/search?api_key=\(kGiphyApiKey)&offset=\(kGiphyPageOffset)&limit=\(kGiphyPageSize)&q=\(queryEncoded)"
guard ContentProxy.configureSessionManager(sessionManager: sessionManager, forUrl: urlString) else {
owsFailDebug("Could not configure query: \(query).")
failure(nil)
return
}
2017-09-24 05:37:30 +02:00
sessionManager.get(urlString,
2019-01-23 18:24:36 +01:00
parameters: [String: AnyObject](),
2020-11-12 03:20:28 +01:00
headers: nil,
progress: nil,
2017-09-24 05:37:30 +02:00
success: { _, value in
2018-08-23 16:37:34 +02:00
Logger.error("search request succeeded")
guard let imageInfos = self.parseGiphyImages(responseJson: value) else {
2017-10-02 21:24:57 +02:00
failure(nil)
2017-09-28 20:09:11 +02:00
return
}
success(imageInfos)
2017-09-24 05:37:30 +02:00
},
failure: { _, error in
2018-08-23 16:37:34 +02:00
Logger.error("search request failed: \(error)")
2017-10-02 21:24:57 +02:00
failure(error as NSError)
2017-09-24 05:37:30 +02:00
})
}
2017-09-24 06:44:35 +02:00
2017-09-29 05:30:33 +02:00
// MARK: Parse API Responses
private func parseGiphyImages(responseJson: Any?) -> [GiphyImageInfo]? {
2017-09-24 06:44:35 +02:00
guard let responseJson = responseJson else {
2018-08-23 16:37:34 +02:00
Logger.error("Missing response.")
2017-09-24 06:44:35 +02:00
return nil
}
guard let responseDict = responseJson as? [String: Any] else {
2018-08-23 16:37:34 +02:00
Logger.error("Invalid response.")
2017-09-24 06:44:35 +02:00
return nil
}
guard let imageDicts = responseDict["data"] as? [[String: Any]] else {
2018-08-23 16:37:34 +02:00
Logger.error("Invalid response data.")
2017-09-24 06:44:35 +02:00
return nil
}
2018-06-01 20:20:48 +02:00
return imageDicts.compactMap { imageDict in
2017-10-02 21:24:57 +02:00
return parseGiphyImage(imageDict: imageDict)
2017-09-24 06:44:35 +02:00
}
}
// Giphy API results are often incomplete or malformed, so we need to be defensive.
private func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? {
2017-09-24 06:44:35 +02:00
guard let giphyId = imageDict["id"] as? String else {
2018-08-23 16:37:34 +02:00
Logger.warn("Image dict missing id.")
2017-09-24 06:44:35 +02:00
return nil
}
guard giphyId.count > 0 else {
2018-08-23 16:37:34 +02:00
Logger.warn("Image dict has invalid id.")
2017-09-24 06:44:35 +02:00
return nil
}
guard let renditionDicts = imageDict["images"] as? [String: Any] else {
2018-08-23 16:37:34 +02:00
Logger.warn("Image dict missing renditions.")
2017-09-24 06:44:35 +02:00
return nil
}
var renditions = [GiphyRendition]()
for (renditionName, renditionDict) in renditionDicts {
guard let renditionDict = renditionDict as? [String: Any] else {
2018-08-23 16:37:34 +02:00
Logger.warn("Invalid rendition dict.")
2017-09-24 06:44:35 +02:00
continue
}
guard let rendition = parseGiphyRendition(renditionName: renditionName,
renditionDict: renditionDict) else {
2017-09-24 06:44:35 +02:00
continue
}
renditions.append(rendition)
}
guard renditions.count > 0 else {
2018-08-23 16:37:34 +02:00
Logger.warn("Image has no valid renditions.")
2017-09-24 06:44:35 +02:00
return nil
}
2017-09-29 00:15:18 +02:00
guard let originalRendition = findOriginalRendition(renditions: renditions) else {
2018-08-23 16:37:34 +02:00
Logger.warn("Image has no original rendition.")
2017-09-29 00:15:18 +02:00
return nil
}
return GiphyImageInfo(giphyId: giphyId,
renditions: renditions,
2017-09-29 00:15:18 +02:00
originalRendition: originalRendition)
}
private func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? {
2017-10-01 20:54:39 +02:00
for rendition in renditions where rendition.name == "original" {
return rendition
2017-09-29 00:15:18 +02:00
}
return nil
2017-09-24 06:44:35 +02:00
}
// Giphy API results are often incomplete or malformed, so we need to be defensive.
2017-10-01 00:22:08 +02:00
//
// We should discard renditions which are missing or have invalid properties.
2017-09-24 06:44:35 +02:00
private func parseGiphyRendition(renditionName: String,
renditionDict: [String: Any]) -> GiphyRendition? {
guard let width = parsePositiveUInt(dict: renditionDict, key: "width", typeName: "rendition") else {
2017-09-24 06:44:35 +02:00
return nil
}
guard let height = parsePositiveUInt(dict: renditionDict, key: "height", typeName: "rendition") else {
2017-09-24 06:44:35 +02:00
return nil
}
// Be lenient when parsing file sizes - we don't require them for stills.
let fileSize = parseLenientUInt(dict: renditionDict, key: "size")
2017-09-24 06:44:35 +02:00
guard let urlString = renditionDict["url"] as? String else {
return nil
}
guard urlString.count > 0 else {
2018-08-23 16:37:34 +02:00
Logger.warn("Rendition has invalid url.")
2017-09-24 06:44:35 +02:00
return nil
}
guard let url = NSURL(string: urlString) else {
2018-08-23 16:37:34 +02:00
Logger.warn("Rendition url could not be parsed.")
2017-09-24 06:44:35 +02:00
return nil
}
2017-10-02 21:24:57 +02:00
guard let fileExtension = url.pathExtension?.lowercased() else {
2018-08-23 16:37:34 +02:00
Logger.warn("Rendition url missing file extension.")
2017-09-24 06:44:35 +02:00
return nil
}
var format = GiphyFormat.gif
2017-10-02 21:24:57 +02:00
if fileExtension == "gif" {
format = .gif
2017-10-02 21:24:57 +02:00
} else if fileExtension == "jpg" {
format = .jpg
2017-10-02 21:24:57 +02:00
} else if fileExtension == "mp4" {
format = .mp4
2017-10-02 21:24:57 +02:00
} else if fileExtension == "webp" {
return nil
} else {
2018-08-23 16:37:34 +02:00
Logger.warn("Invalid file extension: \(fileExtension).")
2017-09-24 06:44:35 +02:00
return nil
}
return GiphyRendition(
format: format,
name: renditionName,
width: width,
height: height,
fileSize: fileSize,
url: url
2017-09-24 06:44:35 +02:00
)
}
private func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? {
2017-09-24 06:44:35 +02:00
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 {
2018-08-23 16:37:34 +02:00
Logger.verbose("\(typeName) has non-positive \(key): \(parsedValue).")
2017-09-24 06:44:35 +02:00
return nil
}
return parsedValue
}
private 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
}
2017-09-24 05:37:30 +02:00
}