mirror of https://github.com/oxen-io/session-ios
Merge branch 'mkirk/multi-image-send'
commit
a5c4d1b9ed
@ -0,0 +1,433 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Photos
|
||||
import PromiseKit
|
||||
|
||||
@objc(OWSImagePickerControllerDelegate)
|
||||
protocol ImagePickerControllerDelegate {
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, didPickImageAttachments attachments: [SignalAttachment])
|
||||
}
|
||||
|
||||
@objc(OWSImagePickerGridController)
|
||||
class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate {
|
||||
|
||||
@objc
|
||||
weak var delegate: ImagePickerControllerDelegate?
|
||||
|
||||
private let library: PhotoLibrary = PhotoLibrary()
|
||||
private let libraryAlbum: PhotoLibraryAlbum
|
||||
|
||||
var availableWidth: CGFloat = 0
|
||||
|
||||
var collectionViewFlowLayout: UICollectionViewFlowLayout
|
||||
|
||||
init() {
|
||||
collectionViewFlowLayout = type(of: self).buildLayout()
|
||||
libraryAlbum = library.albumForAllPhotos()
|
||||
super.init(collectionViewLayout: collectionViewFlowLayout)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: View Lifecycle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.title = libraryAlbum.localizedTitle
|
||||
|
||||
library.delegate = self
|
||||
|
||||
guard let collectionView = collectionView else {
|
||||
owsFailDebug("collectionView was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
collectionView.register(PhotoGridViewCell.self, forCellWithReuseIdentifier: PhotoGridViewCell.reuseIdentifier)
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
|
||||
target: self,
|
||||
action: #selector(didPressCancel))
|
||||
let featureFlag_isMultiselectEnabled = true
|
||||
if featureFlag_isMultiselectEnabled {
|
||||
updateSelectButton()
|
||||
}
|
||||
|
||||
collectionView.backgroundColor = Theme.backgroundColor
|
||||
}
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
updateLayout()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
// Determine the size of the thumbnails to request
|
||||
let scale = UIScreen.main.scale
|
||||
let cellSize = collectionViewFlowLayout.itemSize
|
||||
libraryAlbum.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@objc
|
||||
func didPressCancel(sender: UIBarButtonItem) {
|
||||
self.dismiss(animated: true)
|
||||
}
|
||||
|
||||
// MARK: Layout
|
||||
|
||||
static let kInterItemSpacing: CGFloat = 2
|
||||
private class func buildLayout() -> UICollectionViewFlowLayout {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
|
||||
if #available(iOS 11, *) {
|
||||
layout.sectionInsetReference = .fromSafeArea
|
||||
}
|
||||
layout.minimumInteritemSpacing = kInterItemSpacing
|
||||
layout.minimumLineSpacing = kInterItemSpacing
|
||||
layout.sectionHeadersPinToVisibleBounds = true
|
||||
|
||||
return layout
|
||||
}
|
||||
|
||||
func updateLayout() {
|
||||
let containerWidth: CGFloat
|
||||
if #available(iOS 11.0, *) {
|
||||
containerWidth = self.view.safeAreaLayoutGuide.layoutFrame.size.width
|
||||
} else {
|
||||
containerWidth = self.view.frame.size.width
|
||||
}
|
||||
|
||||
let kItemsPerPortraitRow = 4
|
||||
let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
|
||||
let approxItemWidth = screenWidth / CGFloat(kItemsPerPortraitRow)
|
||||
|
||||
let itemCount = round(containerWidth / approxItemWidth)
|
||||
let spaceWidth = (itemCount + 1) * type(of: self).kInterItemSpacing
|
||||
let availableWidth = containerWidth - spaceWidth
|
||||
|
||||
let itemWidth = floor(availableWidth / CGFloat(itemCount))
|
||||
let newItemSize = CGSize(width: itemWidth, height: itemWidth)
|
||||
|
||||
if (newItemSize != collectionViewFlowLayout.itemSize) {
|
||||
collectionViewFlowLayout.itemSize = newItemSize
|
||||
collectionViewFlowLayout.invalidateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Batch Selection
|
||||
|
||||
lazy var doneButton: UIBarButtonItem = {
|
||||
return UIBarButtonItem(barButtonSystemItem: .done,
|
||||
target: self,
|
||||
action: #selector(didPressDone))
|
||||
}()
|
||||
|
||||
lazy var selectButton: UIBarButtonItem = {
|
||||
return UIBarButtonItem(title: NSLocalizedString("BUTTON_SELECT", comment: "Button text to enable batch selection mode"),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(didTapSelect))
|
||||
}()
|
||||
|
||||
var isInBatchSelectMode = false {
|
||||
didSet {
|
||||
collectionView!.allowsMultipleSelection = isInBatchSelectMode
|
||||
updateSelectButton()
|
||||
updateDoneButton()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func didPressDone(_ sender: Any) {
|
||||
Logger.debug("")
|
||||
|
||||
guard let collectionView = self.collectionView else {
|
||||
owsFailDebug("collectionView was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
guard let indexPaths = collectionView.indexPathsForSelectedItems else {
|
||||
owsFailDebug("indexPaths was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
let assets: [PHAsset] = indexPaths.compactMap { return self.libraryAlbum.asset(at: $0.row) }
|
||||
let promises = assets.map { return libraryAlbum.outgoingAttachment(for: $0) }
|
||||
when(fulfilled: promises).map { attachments in
|
||||
self.dismiss(animated: true) {
|
||||
self.delegate?.imagePicker(self, didPickImageAttachments: attachments)
|
||||
}
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
|
||||
func updateDoneButton() {
|
||||
guard let collectionView = self.collectionView else {
|
||||
owsFailDebug("collectionView was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
if let count = collectionView.indexPathsForSelectedItems?.count, count > 0 {
|
||||
self.doneButton.isEnabled = true
|
||||
} else {
|
||||
self.doneButton.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
func updateSelectButton() {
|
||||
navigationItem.rightBarButtonItem = isInBatchSelectMode ? doneButton : selectButton
|
||||
}
|
||||
|
||||
@objc
|
||||
func didTapSelect(_ sender: Any) {
|
||||
isInBatchSelectMode = true
|
||||
|
||||
// disabled until at least one item is selected
|
||||
self.doneButton.isEnabled = false
|
||||
}
|
||||
|
||||
@objc
|
||||
func didCancelSelect(_ sender: Any) {
|
||||
endSelectMode()
|
||||
}
|
||||
|
||||
func endSelectMode() {
|
||||
isInBatchSelectMode = false
|
||||
|
||||
guard let collectionView = self.collectionView else {
|
||||
owsFailDebug("collectionView was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
// deselect any selected
|
||||
collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false)}
|
||||
}
|
||||
|
||||
// MARK: PhotoLibraryDelegate
|
||||
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||
collectionView?.reloadData()
|
||||
}
|
||||
|
||||
// MARK: UICollectionView
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
if isInBatchSelectMode {
|
||||
updateDoneButton()
|
||||
} else {
|
||||
let asset = libraryAlbum.asset(at: indexPath.row)
|
||||
firstly {
|
||||
libraryAlbum.outgoingAttachment(for: asset)
|
||||
}.map { attachment in
|
||||
self.dismiss(animated: true) {
|
||||
self.delegate?.imagePicker(self, didPickImageAttachments: [attachment])
|
||||
}
|
||||
}.retainUntilComplete()
|
||||
}
|
||||
}
|
||||
|
||||
public override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
|
||||
Logger.debug("")
|
||||
|
||||
if isInBatchSelectMode {
|
||||
updateDoneButton()
|
||||
}
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return libraryAlbum.count
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoGridViewCell.reuseIdentifier, for: indexPath) as? PhotoGridViewCell else {
|
||||
owsFail("cell was unexpectedly nil")
|
||||
}
|
||||
|
||||
let mediaItem = libraryAlbum.mediaItem(at: indexPath.item)
|
||||
cell.configure(item: mediaItem)
|
||||
return cell
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protocol PhotoLibraryDelegate: class {
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
|
||||
}
|
||||
|
||||
class ImagePickerGridItem: PhotoGridItem {
|
||||
|
||||
let asset: PHAsset
|
||||
let album: PhotoLibraryAlbum
|
||||
|
||||
init(asset: PHAsset, album: PhotoLibraryAlbum) {
|
||||
self.asset = asset
|
||||
self.album = album
|
||||
}
|
||||
|
||||
// MARK: PhotoGridItem
|
||||
|
||||
var type: PhotoGridItemType {
|
||||
if asset.mediaType == .video {
|
||||
return .video
|
||||
}
|
||||
|
||||
// TODO show GIF badge?
|
||||
|
||||
return .photo
|
||||
}
|
||||
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? {
|
||||
album.requestThumbnail(for: self.asset) { image, _ in
|
||||
completion(image)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
class PhotoLibraryAlbum {
|
||||
|
||||
let fetchResult: PHFetchResult<PHAsset>
|
||||
let localizedTitle: String?
|
||||
var thumbnailSize: CGSize = .zero
|
||||
|
||||
enum PhotoLibraryError: Error {
|
||||
case assertionError(_ description: String)
|
||||
case unsupportedMediaType
|
||||
|
||||
}
|
||||
|
||||
init(fetchResult: PHFetchResult<PHAsset>, localizedTitle: String?) {
|
||||
self.fetchResult = fetchResult
|
||||
self.localizedTitle = localizedTitle
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
return fetchResult.count
|
||||
}
|
||||
|
||||
private let imageManager = PHCachingImageManager()
|
||||
|
||||
func asset(at index: Int) -> PHAsset {
|
||||
return fetchResult.object(at: index)
|
||||
}
|
||||
|
||||
func mediaItem(at index: Int) -> ImagePickerGridItem {
|
||||
let mediaAsset = asset(at: index)
|
||||
return ImagePickerGridItem(asset: mediaAsset, album: self)
|
||||
}
|
||||
|
||||
// MARK: ImageManager
|
||||
|
||||
func requestThumbnail(for asset: PHAsset, resultHandler: @escaping (UIImage?, [AnyHashable: Any]?) -> Void) {
|
||||
_ = imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil, resultHandler: resultHandler)
|
||||
}
|
||||
|
||||
private func requestImageDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> {
|
||||
return Promise { resolver in
|
||||
_ = imageManager.requestImageData(for: asset, options: nil) { imageData, dataUTI, orientation, info in
|
||||
guard let imageData = imageData else {
|
||||
resolver.reject(PhotoLibraryError.assertionError("imageData was unexpectedly nil"))
|
||||
return
|
||||
}
|
||||
|
||||
guard let dataUTI = dataUTI else {
|
||||
resolver.reject(PhotoLibraryError.assertionError("dataUTI was unexpectedly nil"))
|
||||
return
|
||||
}
|
||||
|
||||
guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else {
|
||||
resolver.reject(PhotoLibraryError.assertionError("dataSource was unexpectedly nil"))
|
||||
return
|
||||
}
|
||||
|
||||
resolver.fulfill((dataSource: dataSource, dataUTI: dataUTI))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestVideoDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> {
|
||||
return Promise { resolver in
|
||||
|
||||
_ = imageManager.requestExportSession(forVideo: asset, options: nil, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, info in
|
||||
|
||||
guard let exportSession = exportSession else {
|
||||
resolver.reject(PhotoLibraryError.assertionError("exportSession was unexpectedly nil"))
|
||||
return
|
||||
}
|
||||
|
||||
exportSession.outputFileType = AVFileType.mp4
|
||||
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
|
||||
|
||||
let exportPath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4")
|
||||
let exportURL = URL(fileURLWithPath: exportPath)
|
||||
exportSession.outputURL = exportURL
|
||||
|
||||
Logger.debug("starting video export")
|
||||
exportSession.exportAsynchronously {
|
||||
Logger.debug("Completed video export")
|
||||
|
||||
guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else {
|
||||
resolver.reject(PhotoLibraryError.assertionError("Failed to build data source for exported video URL"))
|
||||
return
|
||||
}
|
||||
|
||||
resolver.fulfill((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func outgoingAttachment(for asset: PHAsset) -> Promise<SignalAttachment> {
|
||||
switch asset.mediaType {
|
||||
case .image:
|
||||
return requestImageDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in
|
||||
return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
|
||||
}
|
||||
case .video:
|
||||
return requestVideoDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in
|
||||
return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI)
|
||||
}
|
||||
default:
|
||||
return Promise(error: PhotoLibraryError.unsupportedMediaType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver {
|
||||
weak var delegate: PhotoLibraryDelegate?
|
||||
|
||||
var assetCollection: PHAssetCollection!
|
||||
var availableWidth: CGFloat = 0
|
||||
|
||||
func photoLibraryDidChange(_ changeInstance: PHChange) {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.photoLibraryDidChange(self)
|
||||
}
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
PHPhotoLibrary.shared().register(self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
PHPhotoLibrary.shared().unregisterChangeObserver(self)
|
||||
}
|
||||
|
||||
func albumForAllPhotos() -> PhotoLibraryAlbum {
|
||||
let allPhotosOptions = PHFetchOptions()
|
||||
allPhotosOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
|
||||
let fetchResult = PHAsset.fetchAssets(with: allPhotosOptions)
|
||||
|
||||
let title = NSLocalizedString("PHOTO_PICKER_DEFAULT_ALBUM", comment: "navbar title when viewing the default photo album, which includes all photos")
|
||||
return PhotoLibraryAlbum(fetchResult: fetchResult, localizedTitle: title)
|
||||
}
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
public enum PhotoGridItemType {
|
||||
case photo, animated, video
|
||||
}
|
||||
|
||||
public protocol PhotoGridItem: class {
|
||||
var type: PhotoGridItemType { get }
|
||||
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage?
|
||||
}
|
||||
|
||||
public class PhotoGridViewCell: UICollectionViewCell {
|
||||
|
||||
static let reuseIdentifier = "PhotoGridViewCell"
|
||||
|
||||
public let imageView: UIImageView
|
||||
|
||||
private let contentTypeBadgeView: UIImageView
|
||||
private let selectedBadgeView: UIImageView
|
||||
|
||||
private let highlightedView: UIView
|
||||
private let selectedView: UIView
|
||||
|
||||
var item: PhotoGridItem?
|
||||
|
||||
private static let videoBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_video")
|
||||
private static let animatedBadgeImage = #imageLiteral(resourceName: "ic_gallery_badge_gif")
|
||||
private static let selectedBadgeImage = #imageLiteral(resourceName: "selected_blue_circle")
|
||||
|
||||
override public var isSelected: Bool {
|
||||
didSet {
|
||||
self.selectedBadgeView.isHidden = !self.isSelected
|
||||
self.selectedView.isHidden = !self.isSelected
|
||||
}
|
||||
}
|
||||
|
||||
override public var isHighlighted: Bool {
|
||||
didSet {
|
||||
self.highlightedView.isHidden = !self.isHighlighted
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
|
||||
self.contentTypeBadgeView = UIImageView()
|
||||
contentTypeBadgeView.isHidden = true
|
||||
|
||||
self.selectedBadgeView = UIImageView()
|
||||
selectedBadgeView.image = PhotoGridViewCell.selectedBadgeImage
|
||||
selectedBadgeView.isHidden = true
|
||||
|
||||
self.highlightedView = UIView()
|
||||
highlightedView.alpha = 0.2
|
||||
highlightedView.backgroundColor = Theme.primaryColor
|
||||
highlightedView.isHidden = true
|
||||
|
||||
self.selectedView = UIView()
|
||||
selectedView.alpha = 0.3
|
||||
selectedView.backgroundColor = Theme.backgroundColor
|
||||
selectedView.isHidden = true
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.clipsToBounds = true
|
||||
|
||||
self.contentView.addSubview(imageView)
|
||||
self.contentView.addSubview(contentTypeBadgeView)
|
||||
self.contentView.addSubview(highlightedView)
|
||||
self.contentView.addSubview(selectedView)
|
||||
self.contentView.addSubview(selectedBadgeView)
|
||||
|
||||
imageView.autoPinEdgesToSuperviewEdges()
|
||||
highlightedView.autoPinEdgesToSuperviewEdges()
|
||||
selectedView.autoPinEdgesToSuperviewEdges()
|
||||
|
||||
// Note assets were rendered to match exactly. We don't want to re-size with
|
||||
// content mode lest they become less legible.
|
||||
let kContentTypeBadgeSize = CGSize(width: 18, height: 12)
|
||||
contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .leading, withInset: 3)
|
||||
contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3)
|
||||
contentTypeBadgeView.autoSetDimensions(to: kContentTypeBadgeSize)
|
||||
|
||||
let kSelectedBadgeSize = CGSize(width: 31, height: 31)
|
||||
selectedBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 0)
|
||||
selectedBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 0)
|
||||
selectedBadgeView.autoSetDimensions(to: kSelectedBadgeSize)
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "Unimplemented")
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
var image: UIImage? {
|
||||
get { return imageView.image }
|
||||
set {
|
||||
imageView.image = newValue
|
||||
imageView.backgroundColor = newValue == nil ? Theme.offBackgroundColor : .clear
|
||||
}
|
||||
}
|
||||
|
||||
var contentTypeBadgeImage: UIImage? {
|
||||
get { return contentTypeBadgeView.image }
|
||||
set {
|
||||
contentTypeBadgeView.image = newValue
|
||||
contentTypeBadgeView.isHidden = newValue == nil
|
||||
}
|
||||
}
|
||||
|
||||
public func configure(item: PhotoGridItem) {
|
||||
self.item = item
|
||||
|
||||
self.image = item.asyncThumbnail { image in
|
||||
guard let currentItem = self.item else {
|
||||
return
|
||||
}
|
||||
|
||||
guard currentItem === item else {
|
||||
return
|
||||
}
|
||||
|
||||
if image == nil {
|
||||
Logger.debug("image == nil")
|
||||
}
|
||||
self.image = image
|
||||
}
|
||||
|
||||
switch item.type {
|
||||
case .video:
|
||||
self.contentTypeBadgeImage = PhotoGridViewCell.videoBadgeImage
|
||||
case .animated:
|
||||
self.contentTypeBadgeImage = PhotoGridViewCell.animatedBadgeImage
|
||||
case .photo:
|
||||
self.contentTypeBadgeImage = nil
|
||||
}
|
||||
}
|
||||
|
||||
override public func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
|
||||
self.item = nil
|
||||
self.imageView.image = nil
|
||||
self.contentTypeBadgeView.isHidden = true
|
||||
self.highlightedView.isHidden = true
|
||||
self.selectedView.isHidden = true
|
||||
self.selectedBadgeView.isHidden = true
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue