//
// C o p y r i g h t ( c ) 2 0 1 7 O p e n W h i s p e r S y s t e m s . A l l r i g h t s r e s e r v e d .
//
import Foundation
import MobileCoreServices
enum SignalAttachmentError : Error {
case missingData
case fileSizeTooLarge
case invalidData
case couldNotParseImage
case couldNotConvertToJpeg
case invalidFileFormat
}
extension SignalAttachmentError : LocalizedError {
public var errorDescription : String {
switch self {
case . missingData :
return NSLocalizedString ( " ATTACHMENT_ERROR_MISSING_DATA " , comment : " Attachment error message for attachments without any data " )
case . fileSizeTooLarge :
return NSLocalizedString ( " ATTACHMENT_ERROR_FILE_SIZE_TOO_LARGE " , comment : " Attachment error message for attachments whose data exceed file size limits " )
case . invalidData :
return NSLocalizedString ( " ATTACHMENT_ERROR_INVALID_DATA " , comment : " Attachment error message for attachments with invalid data " )
case . couldNotParseImage :
return NSLocalizedString ( " ATTACHMENT_ERROR_COULD_NOT_PARSE_IMAGE " , comment : " Attachment error message for image attachments which cannot be parsed " )
case . couldNotConvertToJpeg :
return NSLocalizedString ( " ATTACHMENT_ERROR_COULD_NOT_CONVERT_TO_JPEG " , comment : " Attachment error message for image attachments which could not be converted to JPEG " )
case . invalidFileFormat :
return NSLocalizedString ( " ATTACHMENT_ERROR_INVALID_FILE_FORMAT " , comment : " Attachment error message for attachments with an invalid file format " )
}
}
}
enum TSImageQuality {
case uncropped
case high
case medium
case low
}
// R e p r e s e n t s a p o s s i b l e a t t a c h m e n t t o u p l o a d .
// T h e a t t a c h m e n t m a y b e i n v a l i d .
//
// S i g n a l a t t a c h m e n t s a r e s u b j e c t t o v a l i d a t i o n a n d
// i n s o m e c a s e s , f i l e f o r m a t c o n v e r s i o n .
//
// T h i s c l a s s g a t h e r s t h a t l o g i c . I t o f f e r s f a c t o r y m e t h o d s
// f o r a t t a c h m e n t s t h a t d o t h e n e c e s s a r y w o r k .
//
// T h e r e t u r n v a l u e f o r t h e f a c t o r y m e t h o d s w i l l b e n i l i f t h e i n p u t i s n i l .
//
// [ S i g n a l A t t a c h m e n t h a s E r r o r ] w i l l b e t r u e f o r n o n - v a l i d a t t a c h m e n t s .
//
// TODO: P e r h a p s d o c o n v e r s i o n o f f t h e m a i n t h r e a d ?
// TODO: S h o w e r r o r o n e r r o r .
// TODO: S h o w p r o g r e s s o n u p l o a d .
class SignalAttachment : NSObject {
static let TAG = " [SignalAttachment] "
// MARK: P r o p e r t i e s
let data : Data
// A t t a c h m e n t t y p e s a r e i d e n t i f i e d u s i n g U T I s .
//
// S e e : h t t p s : / / d e v e l o p e r . a p p l e . c o m / l i b r a r y / c o n t e n t / d o c u m e n t a t i o n / M i s c e l l a n e o u s / R e f e r e n c e / U T I R e f / A r t i c l e s / S y s t e m - D e c l a r e d U n i f o r m T y p e I d e n t i f i e r s . h t m l
let dataUTI : String
var error : SignalAttachmentError ? {
didSet {
AssertIsOnMainThread ( )
assert ( oldValue = = nil )
Logger . verbose ( " \( SignalAttachment . TAG ) Attachment has error: \( error ) " )
}
}
// T o a v o i d r e d u n d a n t w o r k o f r e p e a t e d l y c o m p r e s s i n g / u n c o m p r e s s i n g
// i m a g e s , w e c a c h e t h e U I I m a g e a s s o c i a t e d w i t h t h i s a t t a c h m e n t i f
// p o s s i b l e .
public var image : UIImage ?
// MARK: C o n s t a n t s
/* *
* Media Size constraints from Signal - Android
*
* https : // g i t h u b . c o m / W h i s p e r S y s t e m s / S i g n a l - A n d r o i d / b l o b / m a s t e r / s r c / o r g / t h o u g h t c r i m e / s e c u r e s m s / m m s / P u s h M e d i a C o n s t r a i n t s . j a v a
*/
static let kMaxFileSizeAnimatedImage = 6 * 1024 * 1024
static let kMaxFileSizeImage = 6 * 1024 * 1024
static let kMaxFileSizeVideo = 100 * 1024 * 1024
static let kMaxFileSizeAudio = 100 * 1024 * 1024
static let kMaxFileSizeGeneric = 100 * 1024 * 1024
// MARK: C o n s t r u c t o r
// T h i s m e t h o d s h o u l d n o t b e c a l l e d d i r e c t l y ; u s e t h e f a c t o r y
// m e t h o d s i n s t e a d .
internal required init ( data : Data , dataUTI : String ) {
self . data = data
self . dataUTI = dataUTI
super . init ( )
}
var hasError : Bool {
return error != nil
}
var errorName : String ? {
guard let error = error else {
// T h i s m e t h o d s h o u l d o n l y b e c a l l e d i f t h e r e i s a n e r r o r .
assert ( false )
return nil
}
return " \( error ) "
}
var localizedErrorDescription : String ? {
guard let error = self . error else {
// T h i s m e t h o d s h o u l d o n l y b e c a l l e d i f t h e r e i s a n e r r o r .
assert ( false )
return nil
}
return " \( error . errorDescription ) "
}
class var missingDataErrorMessage : String {
return SignalAttachmentError . missingData . errorDescription
}
// R e t u r n s t h e M I M E t y p e f o r t h i s a t t a c h m e n t o r n i l i f n o M I M E t y p e
// c a n b e i d e n t i f i e d .
var mimeType : String ? {
let mimeType = UTTypeCopyPreferredTagWithClass ( dataUTI as CFString , kUTTagClassMIMEType )
guard mimeType != nil else {
return nil
}
return mimeType ? . takeRetainedValue ( ) as ? String
}
// R e t u r n s t h e f i l e e x t e n s i o n f o r t h i s a t t a c h m e n t o r n i l i f n o f i l e e x t e n s i o n
// c a n b e i d e n t i f i e d .
var fileExtension : String ? {
guard let fileExtension = UTTypeCopyPreferredTagWithClass ( dataUTI as CFString ,
kUTTagClassFilenameExtension ) else {
return nil
}
return fileExtension . takeRetainedValue ( ) as String
}
private static let allowArbitraryAttachments = false
// R e t u r n s t h e s e t o f U T I s t h a t c o r r e s p o n d t o v a l i d _ i n p u t _ i m a g e f o r m a t s
// f o r S i g n a l a t t a c h m e n t s .
//
// I m a g e a t t a c h m e n t s m a y b e c o n v e r t e d t o a n o t h e r i m a g e f o r m a t b e f o r e
// b e i n g u p l o a d e d .
private class var inputImageUTISet : Set < String > {
return MIMETypeUtil . supportedImageUTITypes ( ) . union ( animatedImageUTISet )
}
// R e t u r n s t h e s e t o f U T I s t h a t c o r r e s p o n d t o v a l i d _ o u t p u t _ i m a g e f o r m a t s
// f o r S i g n a l a t t a c h m e n t s .
private class var outputImageUTISet : Set < String > {
if allowArbitraryAttachments {
return MIMETypeUtil . supportedImageUTITypes ( ) . union ( animatedImageUTISet )
} else {
// U n t i l A n d r o i d c l i e n t c a n h a n d l e a r b i t r a r y a t t a c h m e n t s ,
// r e s t r i c t o u t p u t .
return [
kUTTypeJPEG as String ,
kUTTypeGIF as String ,
kUTTypePNG as String
]
}
}
// R e t u r n s t h e s e t o f U T I s t h a t c o r r e s p o n d t o v a l i d a n i m a t e d i m a g e f o r m a t s
// f o r S i g n a l a t t a c h m e n t s .
private class var animatedImageUTISet : Set < String > {
return MIMETypeUtil . supportedAnimatedImageUTITypes ( )
}
// R e t u r n s t h e s e t o f U T I s t h a t c o r r e s p o n d t o v a l i d v i d e o f o r m a t s
// f o r S i g n a l a t t a c h m e n t s .
private class var videoUTISet : Set < String > {
if allowArbitraryAttachments {
return MIMETypeUtil . supportedVideoUTITypes ( )
} else {
return [
kUTTypeMPEG4 as String
]
}
}
// R e t u r n s t h e s e t o f U T I s t h a t c o r r e s p o n d t o v a l i d a u d i o f o r m a t s
// f o r S i g n a l a t t a c h m e n t s .
private class var audioUTISet : Set < String > {
if allowArbitraryAttachments {
return MIMETypeUtil . supportedAudioUTITypes ( )
} else {
return [
kUTTypeMP3 as String ,
kUTTypeMPEG4Audio as String
]
}
}
// R e t u r n s t h e s e t o f U T I s t h a t c o r r e s p o n d t o v a l i d i n p u t f o r m a t s
// f o r S i g n a l a t t a c h m e n t s .
public class var validInputUTISet : Set < String > {
return inputImageUTISet . union ( videoUTISet . union ( audioUTISet ) )
}
public var isImage : Bool {
return SignalAttachment . outputImageUTISet . contains ( dataUTI )
}
public var isVideo : Bool {
return SignalAttachment . videoUTISet . contains ( dataUTI )
}
public var isAudio : Bool {
return SignalAttachment . audioUTISet . contains ( dataUTI )
}
// R e t u r n s a n a t t a c h m e n t f r o m t h e p a s t e b o a r d , o r n i l i f n o a t t a c h m e n t
// c a n b e f o u n d .
//
// N O T E : T h e a t t a c h m e n t r e t u r n e d b y t h i s m e t h o d m a y n o t b e v a l i d .
// C h e c k t h e a t t a c h m e n t ' s e r r o r p r o p e r t y .
public class func attachmentFromPasteboard ( ) -> SignalAttachment ? {
guard UIPasteboard . general . numberOfItems >= 1 else {
return nil
}
// I f p a s t e b o a r d c o n t a i n s m u l t i p l e i t e m s , u s e o n l y t h e f i r s t .
let itemSet = IndexSet ( integer : 0 )
guard let pasteboardUTITypes = UIPasteboard . general . types ( forItemSet : itemSet ) else {
return nil
}
let pasteboardUTISet = Set < String > ( pasteboardUTITypes [ 0 ] )
for dataUTI in inputImageUTISet {
if pasteboardUTISet . contains ( dataUTI ) {
guard let data = dataForFirstPasteboardItem ( dataUTI : dataUTI ) else {
Logger . verbose ( " \( TAG ) Missing expected pasteboard data for UTI: \( dataUTI ) " )
return nil
}
return imageAttachment ( data : data , dataUTI : dataUTI )
}
}
for dataUTI in videoUTISet {
if pasteboardUTISet . contains ( dataUTI ) {
guard let data = dataForFirstPasteboardItem ( dataUTI : dataUTI ) else {
Logger . verbose ( " \( TAG ) Missing expected pasteboard data for UTI: \( dataUTI ) " )
return nil
}
return videoAttachment ( data : data , dataUTI : dataUTI )
}
}
for dataUTI in audioUTISet {
if pasteboardUTISet . contains ( dataUTI ) {
guard let data = dataForFirstPasteboardItem ( dataUTI : dataUTI ) else {
Logger . verbose ( " \( TAG ) Missing expected pasteboard data for UTI: \( dataUTI ) " )
return nil
}
return audioAttachment ( data : data , dataUTI : dataUTI )
}
}
// TODO: W e c o u l d h a n d l e g e n e r i c a t t a c h m e n t s a t t h i s p o i n t .
return nil
}
// T h i s m e t h o d s h o u l d o n l y b e c a l l e d f o r d a t a U T I s t h a t
// a r e a p p r o p r i a t e f o r t h e f i r s t p a s t e b o a r d i t e m .
private class func dataForFirstPasteboardItem ( dataUTI : String ) -> Data ? {
let itemSet = IndexSet ( integer : 0 )
guard let datas = UIPasteboard . general . data ( forPasteboardType : dataUTI , inItemSet : itemSet ) else {
Logger . verbose ( " \( TAG ) Missing expected pasteboard data for UTI: \( dataUTI ) " )
return nil
}
guard datas . count > 0 else {
Logger . verbose ( " \( TAG ) Missing expected pasteboard data for UTI: \( dataUTI ) " )
return nil
}
guard let data = datas [ 0 ] as ? Data else {
Logger . verbose ( " \( TAG ) Missing expected pasteboard data for UTI: \( dataUTI ) " )
return nil
}
return data
}
// MARK: I m a g e A t t a c h m e n t s
// F a c t o r y m e t h o d f o r a n i m a g e a t t a c h m e n t .
//
// N O T E : T h e a t t a c h m e n t r e t u r n e d b y t h i s m e t h o d m a y n o t b e v a l i d .
// C h e c k t h e a t t a c h m e n t ' s e r r o r p r o p e r t y .
public class func imageAttachment ( data imageData : Data ? , dataUTI : String ) -> SignalAttachment {
assert ( dataUTI . characters . count > 0 )
assert ( imageData != nil )
guard let imageData = imageData else {
let attachment = SignalAttachment ( data : Data ( ) , dataUTI : dataUTI )
attachment . error = . missingData
return attachment
}
let attachment = SignalAttachment ( data : imageData , dataUTI : dataUTI )
guard inputImageUTISet . contains ( dataUTI ) else {
attachment . error = . invalidFileFormat
return attachment
}
guard imageData . count > 0 else {
assert ( imageData . count > 0 )
attachment . error = . invalidData
return attachment
}
if animatedImageUTISet . contains ( dataUTI ) {
guard imageData . count <= kMaxFileSizeAnimatedImage else {
attachment . error = . fileSizeTooLarge
return attachment
}
// N e v e r r e - e n c o d e a n i m a t e d i m a g e s ( i . e . G I F s ) a s J P E G s .
Logger . verbose ( " \( TAG ) Sending raw \( attachment . mimeType ) to retain any animation " )
return attachment
} else {
guard let image = UIImage ( data : imageData ) else {
attachment . error = . couldNotParseImage
return attachment
}
attachment . image = image
if isInputImageValidOutputImage ( image : image , imageData : imageData , dataUTI : dataUTI ) {
Logger . verbose ( " \( TAG ) Sending raw \( attachment . mimeType ) " )
return attachment
}
Logger . verbose ( " \( TAG ) Compressing attachment as image/jpeg " )
return compressImageAsJPEG ( image : image , attachment : attachment )
}
}
private class func defaultImageUploadQuality ( ) -> TSImageQuality {
// C u r r e n t l y d e f a u l t t o a m i d d l i n g i m a g e q u a l i t y a n d s i z e .
//
// TODO: W e ' r e l i k e l y t o c h a n g e t h i s b e h a v i o r s o o n .
return . medium
}
// I f t h e p r o p o s e d a t t a c h m e n t a l r e a d y c o n f o r m s t o t h e
// f i l e s i z e a n d c o n t e n t s i z e l i m i t s , d o n ' t r e c o m p r e s s i t .
private class func isInputImageValidOutputImage ( image : UIImage ? , imageData : Data ? , dataUTI : String ) -> Bool {
guard let image = image else {
return false
}
guard let imageData = imageData else {
return false
}
guard SignalAttachment . outputImageUTISet . contains ( dataUTI ) else {
return false
}
let maxSize = maxSizeForImage ( image : image ,
imageUploadQuality : defaultImageUploadQuality ( ) )
if image . size . width <= maxSize &&
image . size . height <= maxSize &&
imageData . count <= kMaxFileSizeImage {
return true
}
return false
}
// F a c t o r y m e t h o d f o r a n i m a g e a t t a c h m e n t .
//
// N O T E : T h e a t t a c h m e n t r e t u r n e d b y t h i s m e t h o d m a y n i l o r n o t b e v a l i d .
// C h e c k t h e a t t a c h m e n t ' s e r r o r p r o p e r t y .
public class func imageAttachment ( image : UIImage ? , dataUTI : String ) -> SignalAttachment {
assert ( dataUTI . characters . count > 0 )
guard let image = image else {
let attachment = SignalAttachment ( data : Data ( ) , dataUTI : dataUTI )
attachment . error = . missingData
return attachment
}
// M a k e a p l a c e h o l d e r a t t a c h m e n t o n w h i c h t o h a n g e r r o r s i f n e c e s s a r y .
let attachment = SignalAttachment ( data : Data ( ) , dataUTI : dataUTI )
attachment . image = image
Logger . verbose ( " \( TAG ) Writing \( attachment . mimeType ) as image/jpeg " )
return compressImageAsJPEG ( image : image , attachment : attachment )
}
private class func compressImageAsJPEG ( image : UIImage , attachment : SignalAttachment ) -> SignalAttachment {
assert ( attachment . error = = nil )
var imageUploadQuality = defaultImageUploadQuality ( )
while true {
let maxSize = maxSizeForImage ( image : image , imageUploadQuality : imageUploadQuality )
var dstImage : UIImage ! = image
if image . size . width > maxSize ||
image . size . height > maxSize {
dstImage = imageScaled ( image , toMaxSize : maxSize )
}
guard let jpgImageData = UIImageJPEGRepresentation ( dstImage ,
jpegCompressionQuality ( imageUploadQuality : imageUploadQuality ) ) else {
attachment . error = . couldNotConvertToJpeg
return attachment
}
if jpgImageData . count <= kMaxFileSizeImage {
let recompressedAttachment = SignalAttachment ( data : jpgImageData , dataUTI : kUTTypeJPEG as String )
recompressedAttachment . image = dstImage
return recompressedAttachment
}
// I f t h e J P E G o u t p u t i s l a r g e r t h a n t h e f i l e s i z e l i m i t ,
// c o n t i n u e t o t r y a g a i n b y p r o g r e s s i v e l y r e d u c i n g t h e
// i m a g e u p l o a d q u a l i t y .
switch imageUploadQuality {
case . uncropped :
imageUploadQuality = . high
case . high :
imageUploadQuality = . medium
case . medium :
imageUploadQuality = . low
case . low :
attachment . error = . fileSizeTooLarge
return attachment
}
}
}
private class func imageScaled ( _ image : UIImage , toMaxSize size : CGFloat ) -> UIImage {
var scaleFactor : CGFloat
let aspectRatio : CGFloat = image . size . height / image . size . width
if aspectRatio > 1 {
scaleFactor = size / image . size . width
} else {
scaleFactor = size / image . size . height
}
let newSize = CGSize ( width : CGFloat ( image . size . width * scaleFactor ) , height : CGFloat ( image . size . height * scaleFactor ) )
UIGraphicsBeginImageContext ( newSize )
image . draw ( in : CGRect ( x : CGFloat ( 0 ) , y : CGFloat ( 0 ) , width : CGFloat ( newSize . width ) , height : CGFloat ( newSize . height ) ) )
let updatedImage : UIImage ? = UIGraphicsGetImageFromCurrentImageContext ( )
UIGraphicsEndImageContext ( )
return updatedImage !
}
private class func maxSizeForImage ( image : UIImage , imageUploadQuality : TSImageQuality ) -> CGFloat {
switch imageUploadQuality {
case . uncropped :
return max ( image . size . width , image . size . height )
case . high :
return 2048
case . medium :
return 1024
case . low :
return 512
}
}
private class func jpegCompressionQuality ( imageUploadQuality : TSImageQuality ) -> CGFloat {
switch imageUploadQuality {
case . uncropped :
return 1
case . high :
return 0.9
case . medium :
return 0.5
case . low :
return 0.3
}
}
// MARK: V i d e o A t t a c h m e n t s
// F a c t o r y m e t h o d f o r v i d e o a t t a c h m e n t s .
//
// N O T E : T h e a t t a c h m e n t r e t u r n e d b y t h i s m e t h o d m a y n o t b e v a l i d .
// C h e c k t h e a t t a c h m e n t ' s e r r o r p r o p e r t y .
public class func videoAttachment ( data : Data ? , dataUTI : String ) -> SignalAttachment {
return newAttachment ( data : data ,
dataUTI : dataUTI ,
validUTISet : videoUTISet ,
maxFileSize : kMaxFileSizeVideo )
}
// MARK: A u d i o A t t a c h m e n t s
// F a c t o r y m e t h o d f o r a u d i o a t t a c h m e n t s .
//
// N O T E : T h e a t t a c h m e n t r e t u r n e d b y t h i s m e t h o d m a y n o t b e v a l i d .
// C h e c k t h e a t t a c h m e n t ' s e r r o r p r o p e r t y .
public class func audioAttachment ( data : Data ? , dataUTI : String ) -> SignalAttachment {
return newAttachment ( data : data ,
dataUTI : dataUTI ,
validUTISet : audioUTISet ,
maxFileSize : kMaxFileSizeAudio )
}
// MARK: G e n e r i c A t t a c h m e n t s
// F a c t o r y m e t h o d f o r g e n e r i c a t t a c h m e n t s .
//
// N O T E : T h e a t t a c h m e n t r e t u r n e d b y t h i s m e t h o d m a y n o t b e v a l i d .
// C h e c k t h e a t t a c h m e n t ' s e r r o r p r o p e r t y .
public class func genericAttachment ( data : Data ? , dataUTI : String ) -> SignalAttachment {
return newAttachment ( data : data ,
dataUTI : dataUTI ,
validUTISet : nil ,
maxFileSize : kMaxFileSizeGeneric )
}
// MARK: H e l p e r M e t h o d s
private class func newAttachment ( data : Data ? ,
dataUTI : String ,
validUTISet : Set < String > ? ,
maxFileSize : Int ) -> SignalAttachment {
assert ( dataUTI . characters . count > 0 )
assert ( data != nil )
guard let data = data else {
let attachment = SignalAttachment ( data : Data ( ) , dataUTI : dataUTI )
attachment . error = . missingData
return attachment
}
let attachment = SignalAttachment ( data : data , dataUTI : dataUTI )
if let validUTISet = validUTISet {
guard validUTISet . contains ( dataUTI ) else {
attachment . error = . invalidFileFormat
return attachment
}
}
guard data . count > 0 else {
assert ( data . count > 0 )
attachment . error = . invalidData
return attachment
}
guard data . count <= maxFileSize else {
attachment . error = . fileSizeTooLarge
return attachment
}
// A t t a c h m e n t i s v a l i d
return attachment
}
}