mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
Updated to the 1.0.0 release of libSession Set the User Config feature flag to July 31st 10am AEST Shifted quote thumbnail generation out of the DBWrite thread Stopped the CurrentUserPoller from polling the user config namespaces if the feature flag is off Fixed an issue where the scrollToBottom behaviour could be a little buggy when an optimistic update is replaced with the proper change Fixed an issue where the 'attachmentsNotUploaded' error wouldn't result in a message entering an error state Fixed a bug where sync messages with attachments weren't being sent
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
|
|
}
|
|
}
|