mirror of https://github.com/oxen-io/session-ios
Merge remote-tracking branch 'origin/release/2.29.2'
commit
f9eab5cd24
@ -0,0 +1,134 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
public enum OWSMediaError: Error {
|
||||
case failure(description: String)
|
||||
}
|
||||
|
||||
@objc public class OWSMediaUtils: NSObject {
|
||||
|
||||
@available(*, unavailable, message:"do not instantiate this class.")
|
||||
private override init() {
|
||||
}
|
||||
|
||||
@objc public class func thumbnail(forImageAtPath path: String, maxDimension: CGFloat) throws -> UIImage {
|
||||
Logger.verbose("thumbnailing image: \(path)")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
throw OWSMediaError.failure(description: "Media file missing.")
|
||||
}
|
||||
guard NSData.ows_isValidImage(atPath: path) else {
|
||||
throw OWSMediaError.failure(description: "Invalid image.")
|
||||
}
|
||||
guard let originalImage = UIImage(contentsOfFile: path) else {
|
||||
throw OWSMediaError.failure(description: "Could not load original image.")
|
||||
}
|
||||
guard let thumbnailImage = originalImage.resized(withMaxDimensionPoints: maxDimension) else {
|
||||
throw OWSMediaError.failure(description: "Could not thumbnail image.")
|
||||
}
|
||||
return thumbnailImage
|
||||
}
|
||||
|
||||
@objc public class func thumbnail(forVideoAtPath path: String, maxDimension: CGFloat) throws -> UIImage {
|
||||
Logger.verbose("thumbnailing video: \(path)")
|
||||
|
||||
guard isVideoOfValidContentTypeAndSize(path: path) else {
|
||||
throw OWSMediaError.failure(description: "Media file has missing or invalid length.")
|
||||
}
|
||||
|
||||
let maxSize = CGSize(width: maxDimension, height: maxDimension)
|
||||
let url = URL(fileURLWithPath: path)
|
||||
let asset = AVURLAsset(url: url, options: nil)
|
||||
guard isValidVideo(asset: asset) else {
|
||||
throw OWSMediaError.failure(description: "Invalid video.")
|
||||
}
|
||||
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.maximumSize = maxSize
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
let time: CMTime = CMTimeMake(1, 60)
|
||||
let cgImage = try generator.copyCGImage(at: time, actualTime: nil)
|
||||
let image = UIImage(cgImage: cgImage)
|
||||
return image
|
||||
}
|
||||
|
||||
@objc public class func isValidVideo(path: String) -> Bool {
|
||||
guard isVideoOfValidContentTypeAndSize(path: path) else {
|
||||
Logger.error("Media file has missing or invalid length.")
|
||||
return false
|
||||
}
|
||||
|
||||
let url = URL(fileURLWithPath: path)
|
||||
let asset = AVURLAsset(url: url, options: nil)
|
||||
return isValidVideo(asset: asset)
|
||||
}
|
||||
|
||||
private class func isVideoOfValidContentTypeAndSize(path: String) -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
Logger.error("Media file missing.")
|
||||
return false
|
||||
}
|
||||
let fileExtension = URL(fileURLWithPath: path).pathExtension
|
||||
guard let contentType = MIMETypeUtil.mimeType(forFileExtension: fileExtension) else {
|
||||
Logger.error("Media file has unknown content type.")
|
||||
return false
|
||||
}
|
||||
guard MIMETypeUtil.isSupportedVideoMIMEType(contentType) else {
|
||||
Logger.error("Media file has invalid content type.")
|
||||
return false
|
||||
}
|
||||
|
||||
guard let fileSize = OWSFileSystem.fileSize(ofPath: path) else {
|
||||
Logger.error("Media file has unknown length.")
|
||||
return false
|
||||
}
|
||||
return fileSize.uintValue <= kMaxFileSizeVideo
|
||||
}
|
||||
|
||||
private class func isValidVideo(asset: AVURLAsset) -> Bool {
|
||||
var maxTrackSize = CGSize.zero
|
||||
for track: AVAssetTrack in asset.tracks(withMediaType: .video) {
|
||||
let trackSize: CGSize = track.naturalSize
|
||||
maxTrackSize.width = max(maxTrackSize.width, trackSize.width)
|
||||
maxTrackSize.height = max(maxTrackSize.height, trackSize.height)
|
||||
}
|
||||
if maxTrackSize.width < 1.0 || maxTrackSize.height < 1.0 {
|
||||
Logger.error("Invalid video size: \(maxTrackSize)")
|
||||
return false
|
||||
}
|
||||
if maxTrackSize.width > kMaxVideoDimensions || maxTrackSize.height > kMaxVideoDimensions {
|
||||
Logger.error("Invalid video dimensions: \(maxTrackSize)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/**
|
||||
* Media Size constraints from Signal-Android
|
||||
*
|
||||
* https://github.com/signalapp/Signal-Android/blob/master/src/org/thoughtcrime/securesms/mms/PushMediaConstraints.java
|
||||
*/
|
||||
@objc
|
||||
public static let kMaxFileSizeAnimatedImage = UInt(25 * 1024 * 1024)
|
||||
@objc
|
||||
public static let kMaxFileSizeImage = UInt(6 * 1024 * 1024)
|
||||
@objc
|
||||
public static let kMaxFileSizeVideo = UInt(100 * 1024 * 1024)
|
||||
@objc
|
||||
public static let kMaxFileSizeAudio = UInt(100 * 1024 * 1024)
|
||||
@objc
|
||||
public static let kMaxFileSizeGeneric = UInt(100 * 1024 * 1024)
|
||||
|
||||
@objc
|
||||
public static let kMaxVideoDimensions: CGFloat = 3 * 1024
|
||||
@objc
|
||||
public static let kMaxAnimatedImageDimensions: UInt = 1 * 1024
|
||||
@objc
|
||||
public static let kMaxStillImageDimensions: UInt = 8 * 1024
|
||||
}
|
@ -0,0 +1,178 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
public enum OWSThumbnailError: Error {
|
||||
case failure(description: String)
|
||||
case assertionFailure(description: String)
|
||||
case externalError(description: String, underlyingError:Error)
|
||||
}
|
||||
|
||||
@objc public class OWSLoadedThumbnail: NSObject {
|
||||
public typealias DataSourceBlock = () throws -> Data
|
||||
|
||||
@objc
|
||||
public let image: UIImage
|
||||
let dataSourceBlock: DataSourceBlock
|
||||
|
||||
@objc
|
||||
public init(image: UIImage, filePath: String) {
|
||||
self.image = image
|
||||
self.dataSourceBlock = {
|
||||
return try Data(contentsOf: URL(fileURLWithPath: filePath))
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public init(image: UIImage, data: Data) {
|
||||
self.image = image
|
||||
self.dataSourceBlock = {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public func data() throws -> Data {
|
||||
return try dataSourceBlock()
|
||||
}
|
||||
}
|
||||
|
||||
private struct OWSThumbnailRequest {
|
||||
public typealias SuccessBlock = (OWSLoadedThumbnail) -> Void
|
||||
public typealias FailureBlock = (Error) -> Void
|
||||
|
||||
let attachment: TSAttachmentStream
|
||||
let thumbnailDimensionPoints: UInt
|
||||
let success: SuccessBlock
|
||||
let failure: FailureBlock
|
||||
|
||||
init(attachment: TSAttachmentStream, thumbnailDimensionPoints: UInt, success: @escaping SuccessBlock, failure: @escaping FailureBlock) {
|
||||
self.attachment = attachment
|
||||
self.thumbnailDimensionPoints = thumbnailDimensionPoints
|
||||
self.success = success
|
||||
self.failure = failure
|
||||
}
|
||||
}
|
||||
|
||||
@objc public class OWSThumbnailService: NSObject {
|
||||
|
||||
// MARK: - Singleton class
|
||||
|
||||
@objc(shared)
|
||||
public static let shared = OWSThumbnailService()
|
||||
|
||||
public typealias SuccessBlock = (OWSLoadedThumbnail) -> Void
|
||||
public typealias FailureBlock = (Error) -> Void
|
||||
|
||||
private let serialQueue = DispatchQueue(label: "OWSThumbnailService")
|
||||
|
||||
// This property should only be accessed on the serialQueue.
|
||||
//
|
||||
// We want to process requests in _reverse_ order in which they
|
||||
// arrive so that we prioritize the most recent view state.
|
||||
private var thumbnailRequestStack = [OWSThumbnailRequest]()
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
|
||||
SwiftSingletons.register(self)
|
||||
}
|
||||
|
||||
private func canThumbnailAttachment(attachment: TSAttachmentStream) -> Bool {
|
||||
return attachment.isImage || attachment.isAnimated || attachment.isVideo
|
||||
}
|
||||
|
||||
// success and failure will be called async _off_ the main thread.
|
||||
@objc
|
||||
public func ensureThumbnail(forAttachment attachment: TSAttachmentStream,
|
||||
thumbnailDimensionPoints: UInt,
|
||||
success: @escaping SuccessBlock,
|
||||
failure: @escaping FailureBlock) {
|
||||
serialQueue.async {
|
||||
let thumbnailRequest = OWSThumbnailRequest(attachment: attachment, thumbnailDimensionPoints: thumbnailDimensionPoints, success: success, failure: failure)
|
||||
self.thumbnailRequestStack.append(thumbnailRequest)
|
||||
|
||||
self.processNextRequestSync()
|
||||
}
|
||||
}
|
||||
|
||||
private func processNextRequestAsync() {
|
||||
serialQueue.async {
|
||||
self.processNextRequestSync()
|
||||
}
|
||||
}
|
||||
|
||||
// This should only be called on the serialQueue.
|
||||
private func processNextRequestSync() {
|
||||
guard let thumbnailRequest = thumbnailRequestStack.popLast() else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let loadedThumbnail = try process(thumbnailRequest: thumbnailRequest)
|
||||
DispatchQueue.global().async {
|
||||
thumbnailRequest.success(loadedThumbnail)
|
||||
}
|
||||
} catch {
|
||||
Logger.error("Could not create thumbnail: \(error)")
|
||||
|
||||
DispatchQueue.global().async {
|
||||
thumbnailRequest.failure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This should only be called on the serialQueue.
|
||||
//
|
||||
// It should be safe to assume that an attachment will never end up with two thumbnails of
|
||||
// the same size since:
|
||||
//
|
||||
// * Thumbnails are only added by this method.
|
||||
// * This method checks for an existing thumbnail using the same connection.
|
||||
// * This method is performed on the serial queue.
|
||||
private func process(thumbnailRequest: OWSThumbnailRequest) throws -> OWSLoadedThumbnail {
|
||||
let attachment = thumbnailRequest.attachment
|
||||
guard canThumbnailAttachment(attachment: attachment) else {
|
||||
throw OWSThumbnailError.failure(description: "Cannot thumbnail attachment.")
|
||||
}
|
||||
let thumbnailPath = attachment.path(forThumbnailDimensionPoints: thumbnailRequest.thumbnailDimensionPoints)
|
||||
if FileManager.default.fileExists(atPath: thumbnailPath) {
|
||||
guard let image = UIImage(contentsOfFile: thumbnailPath) else {
|
||||
throw OWSThumbnailError.failure(description: "Could not load thumbnail.")
|
||||
}
|
||||
return OWSLoadedThumbnail(image: image, filePath: thumbnailPath)
|
||||
}
|
||||
|
||||
Logger.verbose("Creating thumbnail of size: \(thumbnailRequest.thumbnailDimensionPoints)")
|
||||
|
||||
let thumbnailDirPath = (thumbnailPath as NSString).deletingLastPathComponent
|
||||
guard OWSFileSystem.ensureDirectoryExists(thumbnailDirPath) else {
|
||||
throw OWSThumbnailError.failure(description: "Could not create attachment's thumbnail directory.")
|
||||
}
|
||||
guard let originalFilePath = attachment.originalFilePath else {
|
||||
throw OWSThumbnailError.failure(description: "Missing original file path.")
|
||||
}
|
||||
let maxDimension = CGFloat(thumbnailRequest.thumbnailDimensionPoints)
|
||||
let thumbnailImage: UIImage
|
||||
if attachment.isImage || attachment.isAnimated {
|
||||
thumbnailImage = try OWSMediaUtils.thumbnail(forImageAtPath: originalFilePath, maxDimension: maxDimension)
|
||||
} else if attachment.isVideo {
|
||||
thumbnailImage = try OWSMediaUtils.thumbnail(forVideoAtPath: originalFilePath, maxDimension: maxDimension)
|
||||
} else {
|
||||
throw OWSThumbnailError.assertionFailure(description: "Invalid attachment type.")
|
||||
}
|
||||
guard let thumbnailData = UIImageJPEGRepresentation(thumbnailImage, 0.85) else {
|
||||
throw OWSThumbnailError.failure(description: "Could not convert thumbnail to JPEG.")
|
||||
}
|
||||
do {
|
||||
try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath), options: .atomicWrite)
|
||||
} catch let error as NSError {
|
||||
throw OWSThumbnailError.externalError(description: "File write failed: \(thumbnailPath), \(error)", underlyingError: error)
|
||||
}
|
||||
OWSFileSystem.protectFileOrFolder(atPath: thumbnailPath)
|
||||
return OWSLoadedThumbnail(image: thumbnailImage, data: thumbnailData)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue