mirror of https://github.com/oxen-io/session-ios
Merge branch 'charlesmchen/paste'
commit
ec06cf76e4
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "file-icon-large@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
@ -0,0 +1,215 @@
|
||||
//
|
||||
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class AttachmentApprovalViewController: UIViewController {
|
||||
|
||||
let TAG = "[AttachmentApprovalViewController]"
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let attachment: SignalAttachment
|
||||
|
||||
var successCompletion : (() -> Void)?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
@available(*, unavailable, message:"use attachment: constructor instead.")
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
self.attachment = SignalAttachment.genericAttachment(data: nil,
|
||||
dataUTI: kUTTypeContent as String)
|
||||
super.init(coder: aDecoder)
|
||||
assert(false)
|
||||
}
|
||||
|
||||
required init(attachment: SignalAttachment, successCompletion : @escaping () -> Void) {
|
||||
assert(!attachment.hasError)
|
||||
self.attachment = attachment
|
||||
self.successCompletion = successCompletion
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
// MARK: View Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = UIColor.black
|
||||
|
||||
self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem:.done,
|
||||
target:self,
|
||||
action:#selector(donePressed))
|
||||
self.navigationItem.title = NSLocalizedString("ATTACHMENT_APPROVAL_DIALOG_TITLE",
|
||||
comment: "Title for the 'attachment approval' dialog.")
|
||||
|
||||
createViews()
|
||||
}
|
||||
|
||||
// MARK: - Create Views
|
||||
|
||||
private func createViews() {
|
||||
let previewTopMargin: CGFloat = 30
|
||||
let previewHMargin: CGFloat = 20
|
||||
|
||||
let attachmentPreviewView = UIView()
|
||||
self.view.addSubview(attachmentPreviewView)
|
||||
attachmentPreviewView.autoPinWidthToSuperview(withMargin:previewHMargin)
|
||||
attachmentPreviewView.autoPin(toTopLayoutGuideOf: self, withInset:previewTopMargin)
|
||||
|
||||
createButtonRow(attachmentPreviewView:attachmentPreviewView)
|
||||
|
||||
if attachment.isImage {
|
||||
createImagePreview(attachmentPreviewView:attachmentPreviewView)
|
||||
} else {
|
||||
createGenericPreview(attachmentPreviewView:attachmentPreviewView)
|
||||
}
|
||||
}
|
||||
|
||||
private func createImagePreview(attachmentPreviewView: UIView) {
|
||||
var image = attachment.image
|
||||
if image == nil {
|
||||
image = UIImage(data:attachment.data)
|
||||
}
|
||||
if image != nil {
|
||||
let imageView = UIImageView(image:image)
|
||||
imageView.layer.minificationFilter = kCAFilterTrilinear
|
||||
imageView.layer.magnificationFilter = kCAFilterTrilinear
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
attachmentPreviewView.addSubview(imageView)
|
||||
imageView.autoPinWidthToSuperview()
|
||||
imageView.autoPinHeightToSuperview()
|
||||
} else {
|
||||
createGenericPreview(attachmentPreviewView:attachmentPreviewView)
|
||||
}
|
||||
}
|
||||
|
||||
private func createGenericPreview(attachmentPreviewView: UIView) {
|
||||
let stackView = UIView()
|
||||
attachmentPreviewView.addSubview(stackView)
|
||||
stackView.autoCenterInSuperview()
|
||||
|
||||
let imageSize = ScaleFromIPhone5To7Plus(175, 225)
|
||||
let image = UIImage(named:"file-icon-large")
|
||||
assert(image != nil)
|
||||
let imageView = UIImageView(image:image)
|
||||
imageView.layer.minificationFilter = kCAFilterTrilinear
|
||||
imageView.layer.magnificationFilter = kCAFilterTrilinear
|
||||
stackView.addSubview(imageView)
|
||||
imageView.autoHCenterInSuperview()
|
||||
imageView.autoPinEdge(toSuperviewEdge:.top)
|
||||
imageView.autoSetDimension(.width, toSize:imageSize)
|
||||
imageView.autoSetDimension(.height, toSize:imageSize)
|
||||
|
||||
var lastView: UIView = imageView
|
||||
|
||||
let labelFont = UIFont.ows_regularFont(withSize:ScaleFromIPhone5To7Plus(18, 24))
|
||||
|
||||
if let fileExtension = attachment.fileExtension {
|
||||
let fileExtensionLabel = UILabel()
|
||||
fileExtensionLabel.text = String(format:NSLocalizedString("ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT",
|
||||
comment: "Format string for file extension label in call interstitial view"),
|
||||
fileExtension.capitalized)
|
||||
|
||||
fileExtensionLabel.textColor = UIColor.white
|
||||
fileExtensionLabel.font = labelFont
|
||||
fileExtensionLabel.textAlignment = .center
|
||||
stackView.addSubview(fileExtensionLabel)
|
||||
fileExtensionLabel.autoHCenterInSuperview()
|
||||
fileExtensionLabel.autoPinEdge(.top, to:.bottom, of:lastView, withOffset:10)
|
||||
|
||||
lastView = fileExtensionLabel
|
||||
}
|
||||
|
||||
let numberFormatter = NumberFormatter()
|
||||
numberFormatter.numberStyle = NumberFormatter.Style.decimal
|
||||
let fileSizeLabel = UILabel()
|
||||
fileSizeLabel.text = String(format:NSLocalizedString("ATTACHMENT_APPROVAL_FILE_SIZE_FORMAT",
|
||||
comment: "Format string for file size label in call interstitial view"),
|
||||
numberFormatter.string(from: NSNumber(value: attachment.data.count))!)
|
||||
|
||||
fileSizeLabel.textColor = UIColor.white
|
||||
fileSizeLabel.font = labelFont
|
||||
fileSizeLabel.textAlignment = .center
|
||||
stackView.addSubview(fileSizeLabel)
|
||||
fileSizeLabel.autoHCenterInSuperview()
|
||||
fileSizeLabel.autoPinEdge(.top, to:.bottom, of:lastView, withOffset:10)
|
||||
fileSizeLabel.autoPinEdge(toSuperviewEdge:.bottom)
|
||||
}
|
||||
|
||||
private func createButtonRow(attachmentPreviewView: UIView) {
|
||||
let buttonTopMargin = ScaleFromIPhone5To7Plus(30, 40)
|
||||
let buttonBottomMargin = ScaleFromIPhone5To7Plus(25, 40)
|
||||
let buttonHSpacing = ScaleFromIPhone5To7Plus(20, 30)
|
||||
|
||||
let buttonRow = UIView()
|
||||
self.view.addSubview(buttonRow)
|
||||
buttonRow.autoPinWidthToSuperview()
|
||||
buttonRow.autoPinEdge(toSuperviewEdge:.bottom, withInset:buttonBottomMargin)
|
||||
buttonRow.autoPinEdge(.top, to:.bottom, of:attachmentPreviewView, withOffset:buttonTopMargin)
|
||||
|
||||
// We use this invisible subview to ensure that the buttons are centered
|
||||
// horizontally.
|
||||
let buttonSpacer = UIView()
|
||||
buttonRow.addSubview(buttonSpacer)
|
||||
// Vertical positioning of this view doesn't matter.
|
||||
buttonSpacer.autoPinEdge(toSuperviewEdge:.top)
|
||||
buttonSpacer.autoSetDimension(.width, toSize:buttonHSpacing)
|
||||
buttonSpacer.autoHCenterInSuperview()
|
||||
|
||||
let cancelButton = createButton(title: NSLocalizedString("TXT_CANCEL_TITLE",
|
||||
comment: ""),
|
||||
color : UIColor(rgbHex:0xff3B30),
|
||||
action: #selector(cancelPressed))
|
||||
buttonRow.addSubview(cancelButton)
|
||||
cancelButton.autoPinEdge(toSuperviewEdge:.top)
|
||||
cancelButton.autoPinEdge(toSuperviewEdge:.bottom)
|
||||
cancelButton.autoPinEdge(.right, to:.left, of:buttonSpacer)
|
||||
|
||||
let sendButton = createButton(title: NSLocalizedString("ATTACHMENT_APPROVAL_SEND_BUTTON",
|
||||
comment: "Label for 'send' button in the 'attachment approval' dialog."),
|
||||
color : UIColor(rgbHex:0x4CD964),
|
||||
action: #selector(sendPressed))
|
||||
buttonRow.addSubview(sendButton)
|
||||
sendButton.autoPinEdge(toSuperviewEdge:.top)
|
||||
sendButton.autoPinEdge(toSuperviewEdge:.bottom)
|
||||
sendButton.autoPinEdge(.left, to:.right, of:buttonSpacer)
|
||||
}
|
||||
|
||||
private func createButton(title: String, color: UIColor, action: Selector) -> UIButton {
|
||||
let buttonFont = UIFont.ows_mediumFont(withSize:ScaleFromIPhone5To7Plus(18, 22))
|
||||
let buttonCornerRadius = ScaleFromIPhone5To7Plus(4, 5)
|
||||
let buttonWidth = ScaleFromIPhone5To7Plus(110, 140)
|
||||
let buttonHeight = ScaleFromIPhone5To7Plus(35, 45)
|
||||
|
||||
let button = UIButton()
|
||||
button.setTitle(title, for:.normal)
|
||||
button.setTitleColor(UIColor.white, for:.normal)
|
||||
button.titleLabel!.font = buttonFont
|
||||
button.backgroundColor = color
|
||||
button.layer.cornerRadius = buttonCornerRadius
|
||||
button.clipsToBounds = true
|
||||
button.addTarget(self, action:action, for:.touchUpInside)
|
||||
button.autoSetDimension(.width, toSize:buttonWidth)
|
||||
button.autoSetDimension(.height, toSize:buttonHeight)
|
||||
return button
|
||||
}
|
||||
|
||||
// MARK: - Event Handlers
|
||||
|
||||
func donePressed(sender: UIButton) {
|
||||
dismiss(animated: true, completion:nil)
|
||||
}
|
||||
|
||||
func cancelPressed(sender: UIButton) {
|
||||
dismiss(animated: true, completion:nil)
|
||||
}
|
||||
|
||||
func sendPressed(sender: UIButton) {
|
||||
let successCompletion = self.successCompletion
|
||||
dismiss(animated: true, completion: {
|
||||
successCompletion?()
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="11762" systemVersion="15G1217" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" colorMatched="YES">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="11757"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="MessagesViewController">
|
||||
<connections>
|
||||
<outlet property="collectionView" destination="l9u-2b-4LK" id="bLP-6g-CkO"/>
|
||||
<outlet property="inputToolbar" destination="BoD-Az-3DM" id="w74-g9-1qA"/>
|
||||
<outlet property="toolbarBottomLayoutGuide" destination="rHs-6q-NX4" id="d6h-iu-VMX"/>
|
||||
<outlet property="toolbarHeightConstraint" destination="HIk-02-qcW" id="jE8-xC-1eD"/>
|
||||
<outlet property="view" destination="mUa-cS-ru4" id="nki-T1-RTI"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" id="mUa-cS-ru4">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<collectionView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" minimumZoomScale="0.0" maximumZoomScale="0.0" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="l9u-2b-4LK" customClass="JSQMessagesCollectionView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<collectionViewLayout key="collectionViewLayout" id="dZl-7C-LHR" customClass="JSQMessagesCollectionViewFlowLayout"/>
|
||||
<cells/>
|
||||
</collectionView>
|
||||
<toolbar opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="BoD-Az-3DM" customClass="OWSMessagesInputToolbar">
|
||||
<rect key="frame" x="0.0" y="623" width="375" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="44" id="HIk-02-qcW"/>
|
||||
</constraints>
|
||||
<items/>
|
||||
</toolbar>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="BoD-Az-3DM" secondAttribute="trailing" id="7xc-Ha-asg"/>
|
||||
<constraint firstItem="l9u-2b-4LK" firstAttribute="leading" secondItem="mUa-cS-ru4" secondAttribute="leading" id="MmF-oh-Y75"/>
|
||||
<constraint firstAttribute="trailing" secondItem="l9u-2b-4LK" secondAttribute="trailing" id="O9u-TA-A0e"/>
|
||||
<constraint firstAttribute="bottom" secondItem="l9u-2b-4LK" secondAttribute="bottom" id="Re7-WW-UmS"/>
|
||||
<constraint firstItem="l9u-2b-4LK" firstAttribute="top" secondItem="mUa-cS-ru4" secondAttribute="top" id="dCQ-DM-Wdj"/>
|
||||
<constraint firstAttribute="bottom" secondItem="BoD-Az-3DM" secondAttribute="bottom" id="rHs-6q-NX4"/>
|
||||
<constraint firstItem="BoD-Az-3DM" firstAttribute="leading" secondItem="mUa-cS-ru4" secondAttribute="leading" id="ts7-8f-0lH"/>
|
||||
</constraints>
|
||||
<nil key="simulatedStatusBarMetrics"/>
|
||||
</view>
|
||||
</objects>
|
||||
<simulatedMetricsContainer key="defaultSimulatedMetrics">
|
||||
<simulatedStatusBarMetrics key="statusBar"/>
|
||||
<simulatedOrientationMetrics key="orientation"/>
|
||||
<simulatedScreenMetrics key="destination" type="retina4_7.fullscreen"/>
|
||||
</simulatedMetricsContainer>
|
||||
</document>
|
@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="11762" systemVersion="15G1217" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" colorMatched="YES">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="11757"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" id="1" customClass="OWSMessagesToolbarContentView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LEq-G7-jGt" userLabel="Left button container">
|
||||
<rect key="frame" x="8" y="6" width="34" height="32"/>
|
||||
<color key="backgroundColor" red="0.66666666666666663" green="0.66666666666666663" blue="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="32" id="0sE-GV-joM"/>
|
||||
<constraint firstAttribute="width" constant="34" id="eMy-Af-wwH"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Myo-1S-Vg1" userLabel="Right button container">
|
||||
<rect key="frame" x="262" y="6" width="50" height="32"/>
|
||||
<color key="backgroundColor" red="0.66666666666666663" green="0.66666666666666663" blue="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="32" id="NaR-re-dJ4"/>
|
||||
<constraint firstAttribute="width" constant="50" id="yde-S9-dHe"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="dm4-NT-mvr" customClass="OWSMessagesComposerTextView">
|
||||
<rect key="frame" x="50" y="7" width="204" height="30"/>
|
||||
<color key="backgroundColor" red="0.66666666666666663" green="0.66666666666666663" blue="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="Myo-1S-Vg1" firstAttribute="leading" secondItem="dm4-NT-mvr" secondAttribute="trailing" constant="8" id="7Ld-5r-Hp3"/>
|
||||
<constraint firstItem="dm4-NT-mvr" firstAttribute="top" secondItem="1" secondAttribute="top" constant="7" id="9Tz-Wq-xIf"/>
|
||||
<constraint firstAttribute="bottom" secondItem="dm4-NT-mvr" secondAttribute="bottom" constant="7" id="CCb-V7-yek"/>
|
||||
<constraint firstAttribute="bottom" secondItem="Myo-1S-Vg1" secondAttribute="bottom" constant="6" id="EaS-Oq-Qp5"/>
|
||||
<constraint firstItem="LEq-G7-jGt" firstAttribute="leading" secondItem="1" secondAttribute="leading" constant="8" id="LAU-fo-GJJ"/>
|
||||
<constraint firstAttribute="trailing" secondItem="Myo-1S-Vg1" secondAttribute="trailing" constant="8" id="ds6-61-GNv"/>
|
||||
<constraint firstAttribute="bottom" secondItem="LEq-G7-jGt" secondAttribute="bottom" constant="6" id="oG2-YD-ZZI"/>
|
||||
<constraint firstItem="dm4-NT-mvr" firstAttribute="leading" secondItem="LEq-G7-jGt" secondAttribute="trailing" constant="8" id="owo-gB-gyR"/>
|
||||
</constraints>
|
||||
<nil key="simulatedStatusBarMetrics"/>
|
||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||
<connections>
|
||||
<outlet property="leftBarButtonContainerView" destination="LEq-G7-jGt" id="F0V-4N-1Mo"/>
|
||||
<outlet property="leftBarButtonContainerViewWidthConstraint" destination="eMy-Af-wwH" id="FI9-F2-2bN"/>
|
||||
<outlet property="leftHorizontalSpacingConstraint" destination="LAU-fo-GJJ" id="X2c-BI-0Q4"/>
|
||||
<outlet property="rightBarButtonContainerView" destination="Myo-1S-Vg1" id="0SR-cw-EkD"/>
|
||||
<outlet property="rightBarButtonContainerViewWidthConstraint" destination="yde-S9-dHe" id="WGu-df-M3L"/>
|
||||
<outlet property="rightHorizontalSpacingConstraint" destination="ds6-61-GNv" id="ZQh-8M-QFs"/>
|
||||
<outlet property="textView" destination="dm4-NT-mvr" id="PFw-HO-oT8"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="268" y="548"/>
|
||||
</view>
|
||||
</objects>
|
||||
<simulatedMetricsContainer key="defaultSimulatedMetrics">
|
||||
<simulatedStatusBarMetrics key="statusBar"/>
|
||||
<simulatedOrientationMetrics key="orientation"/>
|
||||
<simulatedScreenMetrics key="destination" type="retina4_7.fullscreen"/>
|
||||
</simulatedMetricsContainer>
|
||||
</document>
|
@ -0,0 +1,530 @@
|
||||
//
|
||||
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MobileCoreServices
|
||||
|
||||
enum SignalAttachmentError: String {
|
||||
case missingData
|
||||
case fileSizeTooLarge
|
||||
case invalidData
|
||||
case couldNotParseImage
|
||||
case couldNotConvertToJpeg
|
||||
case invalidFileFormat
|
||||
}
|
||||
|
||||
enum TSImageQuality: Int {
|
||||
case uncropped
|
||||
case high
|
||||
case medium
|
||||
case low
|
||||
}
|
||||
|
||||
// Represents a possible attachment to upload.
|
||||
// The attachment may be invalid.
|
||||
//
|
||||
// Signal attachments are subject to validation and
|
||||
// in some cases, file format conversion.
|
||||
//
|
||||
// This class gathers that logic. It offers factory methods
|
||||
// for attachments that do the necessary work.
|
||||
//
|
||||
// The return value for the factory methods will be nil if the input is nil.
|
||||
//
|
||||
// [SignalAttachment hasError] will be true for non-valid attachments.
|
||||
//
|
||||
// TODO: Perhaps do conversion off the main thread?
|
||||
// TODO: Show error on error.
|
||||
// TODO: Show progress on upload.
|
||||
class SignalAttachment: NSObject {
|
||||
|
||||
static let TAG = "[SignalAttachment]"
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let data: Data
|
||||
|
||||
// Attachment types are identified using UTIs.
|
||||
//
|
||||
// See: https://developer.apple.com/library/content/documentation/Miscellaneous/Reference/UTIRef/Articles/System-DeclaredUniformTypeIdentifiers.html
|
||||
let dataUTI: String
|
||||
|
||||
var error: SignalAttachmentError? {
|
||||
didSet {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
assert(oldValue == nil)
|
||||
Logger.verbose("\(SignalAttachment.TAG) Attachment has error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// To avoid redundant work of repeatedly compressing/uncompressing
|
||||
// images, we cache the UIImage associated with this attachment if
|
||||
// possible.
|
||||
public var image: UIImage?
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/**
|
||||
* Media Size constraints from Signal-Android
|
||||
*
|
||||
* https://github.com/WhisperSystems/Signal-Android/blob/master/src/org/thoughtcrime/securesms/mms/PushMediaConstraints.java
|
||||
*/
|
||||
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: Constructor
|
||||
|
||||
// This method should not be called directly; use the factory
|
||||
// methods instead.
|
||||
internal required init(data: Data, dataUTI: String) {
|
||||
self.data = data
|
||||
self.dataUTI = dataUTI
|
||||
super.init()
|
||||
}
|
||||
|
||||
var hasError: Bool {
|
||||
return error != nil
|
||||
}
|
||||
|
||||
var errorMessage: String? {
|
||||
guard let error = error else {
|
||||
// This method should only be called if there is an error.
|
||||
assert(false)
|
||||
return nil
|
||||
}
|
||||
return "\(error)"
|
||||
}
|
||||
|
||||
// Returns the MIME type for this attachment or nil if no MIME type
|
||||
// can be identified.
|
||||
var mimeType: String? {
|
||||
let mimeType = UTTypeCopyPreferredTagWithClass(dataUTI as CFString, kUTTagClassMIMEType)
|
||||
guard mimeType != nil else {
|
||||
return nil
|
||||
}
|
||||
return mimeType?.takeRetainedValue() as? String
|
||||
}
|
||||
|
||||
// Returns the file extension for this attachment or nil if no file extension
|
||||
// can be identified.
|
||||
var fileExtension: String? {
|
||||
guard let fileExtension = UTTypeCopyPreferredTagWithClass(dataUTI as CFString,
|
||||
kUTTagClassFilenameExtension) else {
|
||||
return nil
|
||||
}
|
||||
return fileExtension.takeRetainedValue() as String
|
||||
}
|
||||
|
||||
private static let allowArbitraryAttachments = false
|
||||
|
||||
// Returns the set of UTIs that correspond to valid _input_ image formats
|
||||
// for Signal attachments.
|
||||
//
|
||||
// Image attachments may be converted to another image format before
|
||||
// being uploaded.
|
||||
private class var inputImageUTISet: Set<String> {
|
||||
return MIMETypeUtil.supportedImageUTITypes()
|
||||
}
|
||||
|
||||
// Returns the set of UTIs that correspond to valid _output_ image formats
|
||||
// for Signal attachments.
|
||||
private class var outputImageUTISet: Set<String> {
|
||||
if allowArbitraryAttachments {
|
||||
return MIMETypeUtil.supportedImageUTITypes()
|
||||
} else {
|
||||
// Until Android client can handle arbitrary attachments,
|
||||
// restrict output.
|
||||
return [
|
||||
kUTTypeJPEG as String,
|
||||
kUTTypeGIF as String,
|
||||
kUTTypePNG as String
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the set of UTIs that correspond to valid animated image formats
|
||||
// for Signal attachments.
|
||||
private class var animatedImageUTISet: Set<String> {
|
||||
return MIMETypeUtil.supportedAnimatedImageUTITypes()
|
||||
}
|
||||
|
||||
// Returns the set of UTIs that correspond to valid video formats
|
||||
// for Signal attachments.
|
||||
private class var videoUTISet: Set<String> {
|
||||
if allowArbitraryAttachments {
|
||||
return MIMETypeUtil.supportedVideoUTITypes()
|
||||
} else {
|
||||
return [
|
||||
kUTTypeMPEG4 as String
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the set of UTIs that correspond to valid audio formats
|
||||
// for Signal attachments.
|
||||
private class var audioUTISet: Set<String> {
|
||||
if allowArbitraryAttachments {
|
||||
return MIMETypeUtil.supportedAudioUTITypes()
|
||||
} else {
|
||||
return [
|
||||
kUTTypeMP3 as String,
|
||||
kUTTypeMPEG4Audio as String
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the set of UTIs that correspond to valid input formats
|
||||
// for Signal attachments.
|
||||
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)
|
||||
}
|
||||
|
||||
// Returns an attachment from the pasteboard, or nil if no attachment
|
||||
// can be found.
|
||||
//
|
||||
// NOTE: The attachment returned by this method may not be valid.
|
||||
// Check the attachment's error property.
|
||||
public class func attachmentFromPasteboard() -> SignalAttachment? {
|
||||
guard UIPasteboard.general.numberOfItems >= 1 else {
|
||||
return nil
|
||||
}
|
||||
// If pasteboard contains multiple items, use only the first.
|
||||
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: We could handle generic attachments at this point.
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// This method should only be called for dataUTIs that
|
||||
// are appropriate for the first pasteboard item.
|
||||
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: Image Attachments
|
||||
|
||||
// Factory method for an image attachment.
|
||||
//
|
||||
// NOTE: The attachment returned by this method may not be valid.
|
||||
// Check the attachment's error property.
|
||||
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
|
||||
}
|
||||
// Never re-encode animated images (i.e. GIFs) as JPEGs.
|
||||
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 {
|
||||
// Currently default to a middling image quality and size.
|
||||
//
|
||||
// TODO: We're likely to change this behavior soon.
|
||||
return .medium
|
||||
}
|
||||
|
||||
// If the proposed attachment already conforms to the
|
||||
// file size and content size limits, don't recompress it.
|
||||
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
|
||||
}
|
||||
|
||||
// Factory method for an image attachment.
|
||||
//
|
||||
// NOTE: The attachment returned by this method may nil or not be valid.
|
||||
// Check the attachment's error property.
|
||||
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
|
||||
}
|
||||
|
||||
// Make a placeholder attachment on which to hang errors if necessary.
|
||||
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
|
||||
}
|
||||
|
||||
// If the JPEG output is larger than the file size limit,
|
||||
// continue to try again by progressively reducing the
|
||||
// image upload quality.
|
||||
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: Video Attachments
|
||||
|
||||
// Factory method for video attachments.
|
||||
//
|
||||
// NOTE: The attachment returned by this method may not be valid.
|
||||
// Check the attachment's error property.
|
||||
public class func videoAttachment(data: Data?, dataUTI: String) -> SignalAttachment {
|
||||
return newAttachment(data : data,
|
||||
dataUTI : dataUTI,
|
||||
validUTISet : videoUTISet,
|
||||
maxFileSize : kMaxFileSizeVideo)
|
||||
}
|
||||
|
||||
// MARK: Audio Attachments
|
||||
|
||||
// Factory method for audio attachments.
|
||||
//
|
||||
// NOTE: The attachment returned by this method may not be valid.
|
||||
// Check the attachment's error property.
|
||||
public class func audioAttachment(data: Data?, dataUTI: String) -> SignalAttachment {
|
||||
return newAttachment(data : data,
|
||||
dataUTI : dataUTI,
|
||||
validUTISet : audioUTISet,
|
||||
maxFileSize : kMaxFileSizeAudio)
|
||||
}
|
||||
|
||||
// MARK: Generic Attachments
|
||||
|
||||
// Factory method for generic attachments.
|
||||
//
|
||||
// NOTE: The attachment returned by this method may not be valid.
|
||||
// Check the attachment's error property.
|
||||
public class func genericAttachment(data: Data?, dataUTI: String) -> SignalAttachment {
|
||||
return newAttachment(data : data,
|
||||
dataUTI : dataUTI,
|
||||
validUTISet : nil,
|
||||
maxFileSize : kMaxFileSizeGeneric)
|
||||
}
|
||||
|
||||
// MARK: Helper Methods
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Attachment is valid
|
||||
return attachment
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue