|
|
|
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
|
|
|
|
|
|
import Foundation
|
|
|
|
|
import GRDB
|
|
|
|
|
import SessionUtilitiesKit
|
|
|
|
|
|
|
|
|
|
public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
|
|
|
|
|
public static var databaseTableName: String { "attachment" }
|
|
|
|
|
internal static let interactionAttachments = belongsTo(InteractionAttachment.self)
|
|
|
|
|
fileprivate static let quote = belongsTo(Quote.self)
|
|
|
|
|
fileprivate static let linkPreview = belongsTo(LinkPreview.self)
|
|
|
|
|
|
|
|
|
|
public typealias Columns = CodingKeys
|
|
|
|
|
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
|
|
|
|
|
case id
|
|
|
|
|
case serverId
|
|
|
|
|
case variant
|
|
|
|
|
case state
|
|
|
|
|
case contentType
|
|
|
|
|
case byteCount
|
|
|
|
|
case creationTimestamp
|
|
|
|
|
case sourceFilename
|
|
|
|
|
case downloadUrl
|
|
|
|
|
case localRelativeFilePath
|
|
|
|
|
case width
|
|
|
|
|
case height
|
|
|
|
|
case encryptionKey
|
|
|
|
|
case digest
|
|
|
|
|
case caption
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public enum Variant: Int, Codable, DatabaseValueConvertible {
|
|
|
|
|
case standard
|
|
|
|
|
case voiceMessage
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public enum State: Int, Codable, DatabaseValueConvertible {
|
|
|
|
|
case pending
|
|
|
|
|
case downloading
|
|
|
|
|
case downloaded
|
|
|
|
|
case uploading
|
|
|
|
|
case uploaded
|
|
|
|
|
case failed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A unique identifier for the attachment
|
|
|
|
|
public let id: String = UUID().uuidString
|
|
|
|
|
|
|
|
|
|
/// The id for the attachment returned by the server
|
|
|
|
|
///
|
|
|
|
|
/// This will be null for attachments which haven’t completed uploading
|
|
|
|
|
///
|
|
|
|
|
/// **Note:** This value is not unique as multiple SOGS could end up having the same file id
|
|
|
|
|
public let serverId: String?
|
|
|
|
|
|
|
|
|
|
/// The type of this attachment, used to distinguish logic handling
|
|
|
|
|
public let variant: Variant
|
|
|
|
|
|
|
|
|
|
/// The current state of the attachment
|
|
|
|
|
public let state: State
|
|
|
|
|
|
|
|
|
|
/// The MIMEType for the attachment
|
|
|
|
|
public let contentType: String
|
|
|
|
|
|
|
|
|
|
/// The size of the attachment in bytes
|
|
|
|
|
///
|
|
|
|
|
/// **Note:** This may be `0` for some legacy attachments
|
|
|
|
|
public let byteCount: UInt
|
|
|
|
|
|
|
|
|
|
/// Timestamp in seconds since epoch for when this attachment was created
|
|
|
|
|
///
|
|
|
|
|
/// **Uploaded:** This will be the timestamp the file finished uploading
|
|
|
|
|
/// **Downloaded:** This will be the timestamp the file finished downloading
|
|
|
|
|
/// **Other:** This will be null
|
|
|
|
|
public let creationTimestamp: TimeInterval?
|
|
|
|
|
|
|
|
|
|
/// Represents the "source" filename sent or received in the protos, not the filename on disk
|
|
|
|
|
public let sourceFilename: String?
|
|
|
|
|
|
|
|
|
|
/// The url the attachment can be downloaded from, this will be `null` for attachments which haven’t yet been uploaded
|
|
|
|
|
///
|
|
|
|
|
/// **Note:** The url is a fully constructed url but the clients just extract the id from the end of the url to perform the actual download
|
|
|
|
|
public let downloadUrl: String?
|
|
|
|
|
|
|
|
|
|
/// The file path for the attachment relative to the attachments folder
|
|
|
|
|
///
|
|
|
|
|
/// **Note:** We store this path so that file path generation changes don’t break existing attachments
|
|
|
|
|
public let localRelativeFilePath: String?
|
|
|
|
|
|
|
|
|
|
/// The width of the attachment, this will be `null` for non-visual attachment types
|
|
|
|
|
public let width: UInt?
|
|
|
|
|
|
|
|
|
|
/// The height of the attachment, this will be `null` for non-visual attachment types
|
|
|
|
|
public let height: UInt?
|
|
|
|
|
|
|
|
|
|
/// The key used to decrypt the attachment
|
|
|
|
|
public let encryptionKey: Data?
|
|
|
|
|
|
|
|
|
|
/// The computed digest for the attachment (generated from `iv || encrypted data || hmac`)
|
|
|
|
|
public let digest: Data?
|
|
|
|
|
|
|
|
|
|
/// Caption for the attachment
|
|
|
|
|
public let caption: String?
|
|
|
|
|
|
|
|
|
|
// MARK: - Initialization
|
|
|
|
|
|
|
|
|
|
public init(
|
|
|
|
|
serverId: String? = nil,
|
|
|
|
|
variant: Variant,
|
|
|
|
|
state: State = .pending,
|
|
|
|
|
contentType: String,
|
|
|
|
|
byteCount: UInt,
|
|
|
|
|
creationTimestamp: TimeInterval? = nil,
|
|
|
|
|
sourceFilename: String? = nil,
|
|
|
|
|
downloadUrl: String? = nil,
|
|
|
|
|
localRelativeFilePath: String? = nil,
|
|
|
|
|
width: UInt? = nil,
|
|
|
|
|
height: UInt? = nil,
|
|
|
|
|
encryptionKey: Data? = nil,
|
|
|
|
|
digest: Data? = nil,
|
|
|
|
|
caption: String? = nil
|
|
|
|
|
) {
|
|
|
|
|
self.serverId = serverId
|
|
|
|
|
self.variant = variant
|
|
|
|
|
self.state = state
|
|
|
|
|
self.contentType = contentType
|
|
|
|
|
self.byteCount = byteCount
|
|
|
|
|
self.creationTimestamp = creationTimestamp
|
|
|
|
|
self.sourceFilename = sourceFilename
|
|
|
|
|
self.downloadUrl = downloadUrl
|
|
|
|
|
self.localRelativeFilePath = localRelativeFilePath
|
|
|
|
|
self.width = width
|
|
|
|
|
self.height = height
|
|
|
|
|
self.encryptionKey = encryptionKey
|
|
|
|
|
self.digest = digest
|
|
|
|
|
self.caption = caption
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public init?(
|
|
|
|
|
variant: Variant = .standard,
|
|
|
|
|
contentType: String,
|
|
|
|
|
dataSource: DataSource
|
|
|
|
|
) {
|
|
|
|
|
guard
|
|
|
|
|
let originalFilePath: String = Attachment.originalFilePath(id: self.id, mimeType: contentType, sourceFilename: nil)
|
|
|
|
|
else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
guard dataSource.write(toPath: originalFilePath) else { return nil }
|
|
|
|
|
|
|
|
|
|
let imageSize: CGSize? = Attachment.imageSize(
|
|
|
|
|
contentType: contentType,
|
|
|
|
|
originalFilePath: originalFilePath
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.serverId = nil
|
|
|
|
|
self.variant = variant
|
|
|
|
|
self.state = .pending
|
|
|
|
|
self.contentType = contentType
|
|
|
|
|
self.byteCount = dataSource.dataLength()
|
|
|
|
|
self.creationTimestamp = nil
|
|
|
|
|
self.sourceFilename = nil
|
|
|
|
|
self.downloadUrl = nil
|
|
|
|
|
self.localRelativeFilePath = nil
|
|
|
|
|
self.width = imageSize.map { UInt(floor($0.width)) }
|
|
|
|
|
self.height = imageSize.map { UInt(floor($0.height)) }
|
|
|
|
|
self.encryptionKey = nil
|
|
|
|
|
self.digest = nil
|
|
|
|
|
self.caption = nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - CustomStringConvertible
|
|
|
|
|
|
|
|
|
|
extension Attachment: CustomStringConvertible {
|
|
|
|
|
public static func description(for variant: Variant, contentType: String, sourceFilename: String?) -> String {
|
|
|
|
|
if MIMETypeUtil.isAudio(contentType) {
|
|
|
|
|
// a missing filename is the legacy way to determine if an audio attachment is
|
|
|
|
|
// a voice note vs. other arbitrary audio attachments.
|
|
|
|
|
if variant == .voiceMessage || sourceFilename == nil || (sourceFilename?.count ?? 0) == 0 {
|
|
|
|
|
return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "\("ATTACHMENT".localized()) \(emoji(for: contentType))"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static func emoji(for contentType: String) -> String {
|
|
|
|
|
if MIMETypeUtil.isImage(contentType) {
|
|
|
|
|
return "📷"
|
|
|
|
|
}
|
|
|
|
|
else if MIMETypeUtil.isVideo(contentType) {
|
|
|
|
|
return "🎥"
|
|
|
|
|
}
|
|
|
|
|
else if MIMETypeUtil.isAudio(contentType) {
|
|
|
|
|
return "🎧"
|
|
|
|
|
}
|
|
|
|
|
else if MIMETypeUtil.isAnimated(contentType) {
|
|
|
|
|
return "🎡"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "📎"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public var description: String {
|
|
|
|
|
return Attachment.description(
|
|
|
|
|
for: variant,
|
|
|
|
|
contentType: contentType,
|
|
|
|
|
sourceFilename: sourceFilename
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Mutation
|
|
|
|
|
|
|
|
|
|
public extension Attachment {
|
|
|
|
|
func with(
|
|
|
|
|
serverId: String? = nil,
|
|
|
|
|
state: State? = nil,
|
|
|
|
|
creationTimestamp: TimeInterval? = nil,
|
|
|
|
|
downloadUrl: String? = nil,
|
|
|
|
|
localRelativeFilePath: String? = nil,
|
|
|
|
|
encryptionKey: Data? = nil,
|
|
|
|
|
digest: Data? = nil
|
|
|
|
|
) -> Attachment {
|
|
|
|
|
return Attachment(
|
|
|
|
|
serverId: (serverId ?? self.serverId),
|
|
|
|
|
variant: variant,
|
|
|
|
|
state: (state ?? self.state),
|
|
|
|
|
contentType: contentType,
|
|
|
|
|
byteCount: byteCount,
|
|
|
|
|
creationTimestamp: (creationTimestamp ?? self.creationTimestamp),
|
|
|
|
|
sourceFilename: sourceFilename,
|
|
|
|
|
downloadUrl: (downloadUrl ?? self.downloadUrl),
|
|
|
|
|
localRelativeFilePath: (localRelativeFilePath ?? self.localRelativeFilePath),
|
|
|
|
|
width: width,
|
|
|
|
|
height: height,
|
|
|
|
|
encryptionKey: (encryptionKey ?? self.encryptionKey),
|
|
|
|
|
digest: (digest ?? self.digest),
|
|
|
|
|
caption: self.caption
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Protobuf
|
|
|
|
|
|
|
|
|
|
public extension Attachment {
|
|
|
|
|
init(proto: SNProtoAttachmentPointer) {
|
|
|
|
|
func inferContentType(from filename: String?) -> String {
|
|
|
|
|
guard
|
|
|
|
|
let fileName: String = filename,
|
|
|
|
|
let fileExtension: String = URL(string: fileName)?.pathExtension
|
|
|
|
|
else { return OWSMimeTypeApplicationOctetStream }
|
|
|
|
|
|
|
|
|
|
return (MIMETypeUtil.mimeType(forFileExtension: fileExtension) ?? OWSMimeTypeApplicationOctetStream)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.serverId = nil
|
|
|
|
|
self.variant = {
|
|
|
|
|
let voiceMessageFlag: Int32 = SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags
|
|
|
|
|
.voiceMessage
|
|
|
|
|
.rawValue
|
|
|
|
|
|
|
|
|
|
guard proto.hasFlags && ((proto.flags & UInt32(voiceMessageFlag)) > 0) else {
|
|
|
|
|
return .standard
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return .voiceMessage
|
|
|
|
|
}()
|
|
|
|
|
self.state = .pending
|
|
|
|
|
self.contentType = (proto.contentType ?? inferContentType(from: proto.fileName))
|
|
|
|
|
self.byteCount = UInt(proto.size)
|
|
|
|
|
self.creationTimestamp = nil
|
|
|
|
|
self.sourceFilename = proto.fileName
|
|
|
|
|
self.downloadUrl = proto.url
|
|
|
|
|
self.localRelativeFilePath = nil
|
|
|
|
|
self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil)
|
|
|
|
|
self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil)
|
|
|
|
|
self.encryptionKey = proto.key
|
|
|
|
|
self.digest = proto.digest
|
|
|
|
|
self.caption = (proto.hasCaption ? proto.caption : nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func buildProto() -> SNProtoAttachmentPointer? {
|
|
|
|
|
guard let serverId: UInt64 = UInt64(self.serverId ?? "") else { return nil }
|
|
|
|
|
|
|
|
|
|
let builder = SNProtoAttachmentPointer.builder(id: serverId)
|
|
|
|
|
builder.setContentType(contentType)
|
|
|
|
|
|
|
|
|
|
if let sourceFilename: String = sourceFilename, !sourceFilename.isEmpty {
|
|
|
|
|
builder.setFileName(sourceFilename)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let caption: String = self.caption, !caption.isEmpty {
|
|
|
|
|
builder.setCaption(caption)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
builder.setSize(UInt32(byteCount))
|
|
|
|
|
builder.setFlags(variant == .voiceMessage ?
|
|
|
|
|
UInt32(SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags.voiceMessage.rawValue) :
|
|
|
|
|
0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if let encryptionKey: Data = encryptionKey, let digest: Data = digest {
|
|
|
|
|
builder.setKey(encryptionKey)
|
|
|
|
|
builder.setDigest(digest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if
|
|
|
|
|
let width: UInt = self.width,
|
|
|
|
|
let height: UInt = self.height,
|
|
|
|
|
width > 0,
|
|
|
|
|
width < Int.max,
|
|
|
|
|
height > 0,
|
|
|
|
|
height < Int.max
|
|
|
|
|
{
|
|
|
|
|
builder.setWidth(UInt32(width))
|
|
|
|
|
builder.setHeight(UInt32(height))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let downloadUrl: String = self.downloadUrl {
|
|
|
|
|
builder.setUrl(downloadUrl)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
return try builder.build()
|
|
|
|
|
}
|
|
|
|
|
catch {
|
|
|
|
|
SNLog("Couldn't construct attachment proto from: \(self).")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - GRDB Interactions
|
|
|
|
|
|
|
|
|
|
public extension Attachment {
|
|
|
|
|
static func fetchAllPendingAttachments(_ db: Database, for threadId: String) throws -> [Attachment] {
|
|
|
|
|
return try Attachment
|
|
|
|
|
.select(Attachment.Columns.allCases + [Interaction.Columns.id])
|
|
|
|
|
.filter(Columns.variant == Variant.standard)
|
|
|
|
|
.filter(Columns.state == State.pending)
|
|
|
|
|
.joining(
|
|
|
|
|
optional: Attachment.interactionAttachments
|
|
|
|
|
.filter(Interaction.Columns.threadId == threadId)
|
|
|
|
|
)
|
|
|
|
|
.joining(
|
|
|
|
|
optional: Attachment.quote
|
|
|
|
|
.joining(
|
|
|
|
|
required: Quote.interaction
|
|
|
|
|
.filter(Interaction.Columns.threadId == threadId)
|
|
|
|
|
)
|
|
|
|
|
)//tmp.authorId
|
|
|
|
|
.joining(
|
|
|
|
|
optional: Attachment.linkPreview
|
|
|
|
|
.joining(
|
|
|
|
|
required: LinkPreview.interactions
|
|
|
|
|
.filter(Interaction.Columns.threadId == threadId)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.order(Interaction.Columns.id.desc) // Newest attachments first
|
|
|
|
|
.fetchAll(db)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Convenience - Static
|
|
|
|
|
|
|
|
|
|
public extension Attachment {
|
|
|
|
|
private static let thumbnailDimensionSmall: UInt = 200
|
|
|
|
|
private static let thumbnailDimensionMedium: UInt = 450
|
|
|
|
|
|
|
|
|
|
/// This size is large enough to render full screen
|
|
|
|
|
private static var thumbnailDimensionsLarge: CGFloat = {
|
|
|
|
|
let screenSizePoints: CGSize = UIScreen.main.bounds.size
|
|
|
|
|
let minZoomFactor: CGFloat = 2 // TODO: Should this be screen scale?
|
|
|
|
|
|
|
|
|
|
return (max(screenSizePoints.width, screenSizePoints.height) * minZoomFactor)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
private static var sharedDataAttachmentsDirPath: String = {
|
|
|
|
|
OWSFileSystem.appSharedDataDirectoryPath().appending("/Attachments")
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
internal static var attachmentsFolder: String = {
|
|
|
|
|
let attachmentsFolder: String = sharedDataAttachmentsDirPath
|
|
|
|
|
OWSFileSystem.ensureDirectoryExists(attachmentsFolder)
|
|
|
|
|
|
|
|
|
|
return attachmentsFolder
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
private static var thumbnailsFolder: String = {
|
|
|
|
|
let attachmentsFolder: String = sharedDataAttachmentsDirPath
|
|
|
|
|
OWSFileSystem.ensureDirectoryExists(attachmentsFolder)
|
|
|
|
|
|
|
|
|
|
return attachmentsFolder
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
internal static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? {
|
|
|
|
|
return MIMETypeUtil.filePath(
|
|
|
|
|
forAttachment: id,
|
|
|
|
|
ofMIMEType: mimeType,
|
|
|
|
|
sourceFilename: sourceFilename,
|
|
|
|
|
inFolder: Attachment.attachmentsFolder
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func imageSize(contentType: String, originalFilePath: String) -> CGSize? {
|
|
|
|
|
let isVideo: Bool = MIMETypeUtil.isVideo(contentType)
|
|
|
|
|
let isImage: Bool = MIMETypeUtil.isImage(contentType)
|
|
|
|
|
let isAnimated: Bool = MIMETypeUtil.isAnimated(contentType)
|
|
|
|
|
|
|
|
|
|
guard isVideo || isImage || isAnimated else { return nil }
|
|
|
|
|
|
|
|
|
|
if isVideo {
|
|
|
|
|
guard OWSMediaUtils.isValidVideo(path: originalFilePath) else { return nil }
|
|
|
|
|
|
|
|
|
|
return Attachment.videoStillImage(filePath: originalFilePath)?.size
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return NSData.imageSize(forFilePath: originalFilePath, mimeType: contentType)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func videoStillImage(filePath: String) -> UIImage? {
|
|
|
|
|
return try? OWSMediaUtils.thumbnail(
|
|
|
|
|
forVideoAtPath: filePath,
|
|
|
|
|
maxDimension: Attachment.thumbnailDimensionsLarge
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Convenience
|
|
|
|
|
|
|
|
|
|
extension Attachment {
|
|
|
|
|
var originalFilePath: String? {
|
|
|
|
|
return Attachment.originalFilePath(
|
|
|
|
|
id: self.id,
|
|
|
|
|
mimeType: self.contentType,
|
|
|
|
|
sourceFilename: self.sourceFilename
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var thumbnailsDirPath: String {
|
|
|
|
|
// Thumbnails are written to the caches directory, so that iOS can
|
|
|
|
|
// remove them if necessary
|
|
|
|
|
return "\(OWSFileSystem.cachesDirectoryPath())/\(id)-thumbnails"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var originalImage: UIImage? {
|
|
|
|
|
guard let originalFilePath: String = originalFilePath else { return nil }
|
|
|
|
|
|
|
|
|
|
if isVideo {
|
|
|
|
|
return Attachment.videoStillImage(filePath: originalFilePath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard isImage || isAnimated else { return nil }
|
|
|
|
|
guard NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType) else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return UIImage(contentsOfFile: originalFilePath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var isImage: Bool { MIMETypeUtil.isImage(contentType) }
|
|
|
|
|
var isVideo: Bool { MIMETypeUtil.isVideo(contentType) }
|
|
|
|
|
var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) }
|
|
|
|
|
|
|
|
|
|
func readDataFromFile() throws -> Data? {
|
|
|
|
|
guard let filePath: String = Attachment.originalFilePath(id: self.id, mimeType: self.contentType, sourceFilename: self.sourceFilename) else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return try Data(contentsOf: URL(fileURLWithPath: filePath))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func thumbnailPath(for dimensions: UInt) -> String {
|
|
|
|
|
return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) {
|
|
|
|
|
guard let width: UInt = self.width, let height: UInt = self.height, width > 1, height > 1 else {
|
|
|
|
|
failure()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// There's no point in generating a thumbnail if the original is smaller than the
|
|
|
|
|
// thumbnail size
|
|
|
|
|
if width < dimensions || height < dimensions {
|
|
|
|
|
guard let image: UIImage = originalImage else {
|
|
|
|
|
failure()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
success(image)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let thumbnailPath = thumbnailPath(for: dimensions)
|
|
|
|
|
|
|
|
|
|
if FileManager.default.fileExists(atPath: thumbnailPath) {
|
|
|
|
|
guard let image: UIImage = UIImage(contentsOfFile: thumbnailPath) else {
|
|
|
|
|
failure()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
success(image)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
OWSThumbnailService.shared.ensureThumbnail(
|
|
|
|
|
for: self,
|
|
|
|
|
dimensions: dimensions,
|
|
|
|
|
success: { loadedThumbnail in success(loadedThumbnail.image) },
|
|
|
|
|
failure: { _ in failure() }
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func thumbnailImageSmallSync() -> UIImage? {
|
|
|
|
|
guard isVideo || isImage || isAnimated else { return nil }
|
|
|
|
|
|
|
|
|
|
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
|
|
|
|
|
var image: UIImage?
|
|
|
|
|
|
|
|
|
|
loadThumbnail(
|
|
|
|
|
with: Attachment.thumbnailDimensionSmall,
|
|
|
|
|
success: { loadedImage in
|
|
|
|
|
image = loadedImage
|
|
|
|
|
semaphore.signal()
|
|
|
|
|
},
|
|
|
|
|
failure: { semaphore.signal() }
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Wait up to 5 seconds for the thumbnail to be loaded
|
|
|
|
|
_ = semaphore.wait(timeout: .now() + .seconds(5))
|
|
|
|
|
|
|
|
|
|
return image
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func cloneAsThumbnail() -> Attachment {
|
|
|
|
|
fatalError("TODO: Add this back")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func write(data: Data) throws -> Bool {
|
|
|
|
|
guard let originalFilePath: String = originalFilePath else { return false }
|
|
|
|
|
|
|
|
|
|
try data.write(to: URL(fileURLWithPath: originalFilePath))
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|