session-ios/Session/Media Viewing & Editing/PhotoCaptureViewController.swift
2021-01-21 16:43:55 +11:00

671 lines
23 KiB
Swift

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import AVFoundation
import PromiseKit
protocol PhotoCaptureViewControllerDelegate: AnyObject {
func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment)
func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController)
}
enum PhotoCaptureError: Error {
case assertionError(description: String)
case initializationFailed
case captureFailed
}
extension PhotoCaptureError: LocalizedError {
var localizedDescription: String {
switch self {
case .initializationFailed:
return NSLocalizedString("PHOTO_CAPTURE_UNABLE_TO_INITIALIZE_CAMERA", comment: "alert title")
case .captureFailed:
return NSLocalizedString("PHOTO_CAPTURE_UNABLE_TO_CAPTURE_IMAGE", comment: "alert title")
case .assertionError:
return NSLocalizedString("PHOTO_CAPTURE_GENERIC_ERROR", comment: "alert title, generic error preventing user from capturing a photo")
}
}
}
class PhotoCaptureViewController: OWSViewController {
weak var delegate: PhotoCaptureViewControllerDelegate?
private var photoCapture: PhotoCapture!
deinit {
UIDevice.current.endGeneratingDeviceOrientationNotifications()
if let photoCapture = photoCapture {
photoCapture.stopCapture().done {
Logger.debug("stopCapture completed")
}.retainUntilComplete()
}
}
// MARK: - Overrides
override func loadView() {
self.view = UIView()
self.view.backgroundColor = .black
}
override func viewDidLoad() {
super.viewDidLoad()
setupPhotoCapture()
setupOrientationMonitoring()
updateNavigationItems()
updateFlashModeControl()
let initialCaptureOrientation = AVCaptureVideoOrientation(deviceOrientation: UIDevice.current.orientation) ?? .portrait
updateIconOrientations(isAnimated: false, captureOrientation: initialCaptureOrientation)
view.addGestureRecognizer(pinchZoomGesture)
view.addGestureRecognizer(focusGesture)
view.addGestureRecognizer(doubleTapToSwitchCameraGesture)
}
override var prefersStatusBarHidden: Bool {
return true
}
// MARK: -
var isRecordingMovie: Bool = false
let recordingTimerView = RecordingTimerView()
func updateNavigationItems() {
if isRecordingMovie {
navigationItem.leftBarButtonItem = nil
navigationItem.rightBarButtonItems = nil
navigationItem.titleView = recordingTimerView
recordingTimerView.sizeToFit()
} else {
navigationItem.titleView = nil
navigationItem.leftBarButtonItem = dismissControl.barButtonItem
let fixedSpace = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
fixedSpace.width = 16
navigationItem.rightBarButtonItems = [switchCameraControl.barButtonItem, fixedSpace, flashModeControl.barButtonItem]
}
}
// HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
// If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
// the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
override public var canBecomeFirstResponder: Bool {
Logger.debug("")
return true
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return .portrait
}
// MARK: - Views
let captureButton = CaptureButton()
var previewView: CapturePreviewView!
class PhotoControl {
let button: OWSButton
let barButtonItem: UIBarButtonItem
init(imageName: String, block: @escaping () -> Void) {
self.button = OWSButton(imageName: imageName, tintColor: .ows_white, block: block)
if #available(iOS 10, *) {
button.autoPinToSquareAspectRatio()
} else {
button.sizeToFit()
}
button.layer.shadowOffset = CGSize.zero
button.layer.shadowOpacity = 0.35
button.layer.shadowRadius = 4
self.barButtonItem = UIBarButtonItem(customView: button)
}
func setImage(imageName: String) {
button.setImage(imageName: imageName)
}
}
private lazy var dismissControl: PhotoControl = {
return PhotoControl(imageName: "X") { [weak self] in
self?.didTapClose()
}
}()
private lazy var switchCameraControl: PhotoControl = {
return PhotoControl(imageName: "ic_switch_camera") { [weak self] in
self?.didTapSwitchCamera()
}
}()
private lazy var flashModeControl: PhotoControl = {
return PhotoControl(imageName: "ic_flash_mode_auto") { [weak self] in
self?.didTapFlashMode()
}
}()
lazy var pinchZoomGesture: UIPinchGestureRecognizer = {
return UIPinchGestureRecognizer(target: self, action: #selector(didPinchZoom(pinchGesture:)))
}()
lazy var focusGesture: UITapGestureRecognizer = {
return UITapGestureRecognizer(target: self, action: #selector(didTapFocusExpose(tapGesture:)))
}()
lazy var doubleTapToSwitchCameraGesture: UITapGestureRecognizer = {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didDoubleTapToSwitchCamera(tapGesture:)))
tapGesture.numberOfTapsRequired = 2
return tapGesture
}()
// MARK: - Events
@objc
func didTapClose() {
self.delegate?.photoCaptureViewControllerDidCancel(self)
}
@objc
func didTapSwitchCamera() {
Logger.debug("")
switchCamera()
}
@objc
func didDoubleTapToSwitchCamera(tapGesture: UITapGestureRecognizer) {
Logger.debug("")
switchCamera()
}
private func switchCamera() {
UIView.animate(withDuration: 0.2) {
let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001
self.switchCameraControl.button.transform = self.switchCameraControl.button.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation)
}
photoCapture.switchCamera().catch { error in
self.showFailureUI(error: error)
}.retainUntilComplete()
}
@objc
func didTapFlashMode() {
Logger.debug("")
photoCapture.switchFlashMode().done {
self.updateFlashModeControl()
}.retainUntilComplete()
}
@objc
func didPinchZoom(pinchGesture: UIPinchGestureRecognizer) {
switch pinchGesture.state {
case .began: fallthrough
case .changed:
photoCapture.updateZoom(scaleFromPreviousZoomFactor: pinchGesture.scale)
case .ended:
photoCapture.completeZoom(scaleFromPreviousZoomFactor: pinchGesture.scale)
default:
break
}
}
@objc
func didTapFocusExpose(tapGesture: UITapGestureRecognizer) {
let viewLocation = tapGesture.location(in: view)
let devicePoint = previewView.previewLayer.captureDevicePointConverted(fromLayerPoint: viewLocation)
photoCapture.focus(with: .autoFocus, exposureMode: .autoExpose, at: devicePoint, monitorSubjectAreaChange: true)
}
// MARK: - Orientation
private func setupOrientationMonitoring() {
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
NotificationCenter.default.addObserver(self,
selector: #selector(didChangeDeviceOrientation),
name: UIDevice.orientationDidChangeNotification,
object: UIDevice.current)
}
var lastKnownCaptureOrientation: AVCaptureVideoOrientation = .portrait
@objc
func didChangeDeviceOrientation(notification: Notification) {
let currentOrientation = UIDevice.current.orientation
if let captureOrientation = AVCaptureVideoOrientation(deviceOrientation: currentOrientation) {
// since the "face up" and "face down" orientations aren't reflected in the photo output,
// we need to capture the last known _other_ orientation so we can reflect the appropriate
// portrait/landscape in our captured photos.
Logger.verbose("lastKnownCaptureOrientation: \(lastKnownCaptureOrientation)->\(captureOrientation)")
lastKnownCaptureOrientation = captureOrientation
updateIconOrientations(isAnimated: true, captureOrientation: captureOrientation)
}
}
// MARK: -
private func updateIconOrientations(isAnimated: Bool, captureOrientation: AVCaptureVideoOrientation) {
Logger.verbose("captureOrientation: \(captureOrientation)")
let transformFromOrientation: CGAffineTransform
switch captureOrientation {
case .portrait:
transformFromOrientation = .identity
case .portraitUpsideDown:
transformFromOrientation = CGAffineTransform(rotationAngle: .pi)
case .landscapeLeft:
transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi)
case .landscapeRight:
transformFromOrientation = CGAffineTransform(rotationAngle: -1 * .halfPi)
}
// Don't "unrotate" the switch camera icon if the front facing camera had been selected.
let tranformFromCameraType: CGAffineTransform = photoCapture.desiredPosition == .front ? CGAffineTransform(rotationAngle: -.pi) : .identity
let updateOrientation = {
self.flashModeControl.button.transform = transformFromOrientation
self.switchCameraControl.button.transform = transformFromOrientation.concatenating(tranformFromCameraType)
}
if isAnimated {
UIView.animate(withDuration: 0.3, animations: updateOrientation)
} else {
updateOrientation()
}
}
private func setupPhotoCapture() {
photoCapture = PhotoCapture()
photoCapture.delegate = self
captureButton.delegate = photoCapture
previewView = CapturePreviewView(session: photoCapture.session)
photoCapture.startCapture().done { [weak self] in
guard let self = self else { return }
self.showCaptureUI()
}.catch { [weak self] error in
guard let self = self else { return }
self.showFailureUI(error: error)
}.retainUntilComplete()
}
private func showCaptureUI() {
Logger.debug("")
view.addSubview(previewView)
if UIDevice.current.hasIPhoneXNotch {
previewView.autoPinEdgesToSuperviewEdges()
} else {
previewView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: 0, leading: 0, bottom: 40, trailing: 0))
}
view.addSubview(captureButton)
captureButton.autoHCenterInSuperview()
captureButton.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: SendMediaNavigationController.bottomButtonsCenterOffset).isActive = true
}
private func showFailureUI(error: Error) {
Logger.error("error: \(error)")
OWSAlerts.showAlert(title: nil,
message: error.localizedDescription,
buttonTitle: CommonStrings.dismissButton,
buttonAction: { [weak self] _ in self?.dismiss(animated: true) })
}
private func updateFlashModeControl() {
let imageName: String
switch photoCapture.flashMode {
case .auto:
imageName = "ic_flash_mode_auto"
case .on:
imageName = "ic_flash_mode_on"
case .off:
imageName = "ic_flash_mode_off"
default: preconditionFailure()
}
self.flashModeControl.setImage(imageName: imageName)
}
}
extension PhotoCaptureViewController: PhotoCaptureDelegate {
// MARK: - Photo
func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment) {
delegate?.photoCaptureViewController(self, didFinishProcessingAttachment: attachment)
}
func photoCapture(_ photoCapture: PhotoCapture, processingDidError error: Error) {
showFailureUI(error: error)
}
// MARK: - Video
func photoCaptureDidBeginVideo(_ photoCapture: PhotoCapture) {
isRecordingMovie = true
updateNavigationItems()
recordingTimerView.startCounting()
}
func photoCaptureDidCompleteVideo(_ photoCapture: PhotoCapture) {
isRecordingMovie = false
recordingTimerView.stopCounting()
updateNavigationItems()
}
func photoCaptureDidCancelVideo(_ photoCapture: PhotoCapture) {
owsFailDebug("If we ever allow this, we should test.")
isRecordingMovie = false
recordingTimerView.stopCounting()
updateNavigationItems()
}
// MARK: -
var zoomScaleReferenceHeight: CGFloat? {
return view.bounds.height
}
var captureOrientation: AVCaptureVideoOrientation {
return lastKnownCaptureOrientation
}
}
// MARK: - Views
protocol CaptureButtonDelegate: AnyObject {
// MARK: Photo
func didTapCaptureButton(_ captureButton: CaptureButton)
// MARK: Video
func didBeginLongPressCaptureButton(_ captureButton: CaptureButton)
func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton)
func didCancelLongPressCaptureButton(_ captureButton: CaptureButton)
var zoomScaleReferenceHeight: CGFloat? { get }
func longPressCaptureButton(_ captureButton: CaptureButton, didUpdateZoomAlpha zoomAlpha: CGFloat)
}
class CaptureButton: UIView {
let innerButton = CircleView()
var tapGesture: UITapGestureRecognizer!
var longPressGesture: UILongPressGestureRecognizer!
let longPressDuration = 0.5
let zoomIndicator = CircleView()
weak var delegate: CaptureButtonDelegate?
let defaultDiameter: CGFloat = ScaleFromIPhone5To7Plus(60, 80)
let recordingDiameter: CGFloat = ScaleFromIPhone5To7Plus(68, 120)
var innerButtonSizeConstraints: [NSLayoutConstraint]!
var zoomIndicatorSizeConstraints: [NSLayoutConstraint]!
override init(frame: CGRect) {
super.init(frame: frame)
tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap))
innerButton.addGestureRecognizer(tapGesture)
longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress))
longPressGesture.minimumPressDuration = longPressDuration
innerButton.addGestureRecognizer(longPressGesture)
addSubview(innerButton)
innerButtonSizeConstraints = autoSetDimensions(to: CGSize(width: defaultDiameter, height: defaultDiameter))
innerButton.backgroundColor = UIColor.ows_white.withAlphaComponent(0.33)
innerButton.layer.shadowOffset = .zero
innerButton.layer.shadowOpacity = 0.33
innerButton.layer.shadowRadius = 2
innerButton.autoPinEdgesToSuperviewEdges()
addSubview(zoomIndicator)
zoomIndicatorSizeConstraints = zoomIndicator.autoSetDimensions(to: CGSize(width: defaultDiameter, height: defaultDiameter))
zoomIndicator.isUserInteractionEnabled = false
zoomIndicator.layer.borderColor = UIColor.ows_white.cgColor
zoomIndicator.layer.borderWidth = 1.5
zoomIndicator.autoAlignAxis(.horizontal, toSameAxisOf: innerButton)
zoomIndicator.autoAlignAxis(.vertical, toSameAxisOf: innerButton)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Gestures
@objc
func didTap(_ gesture: UITapGestureRecognizer) {
delegate?.didTapCaptureButton(self)
}
var initialTouchLocation: CGPoint?
@objc
func didLongPress(_ gesture: UILongPressGestureRecognizer) {
Logger.verbose("")
guard let gestureView = gesture.view else {
owsFailDebug("gestureView was unexpectedly nil")
return
}
switch gesture.state {
case .possible: break
case .began:
initialTouchLocation = gesture.location(in: gesture.view)
delegate?.didBeginLongPressCaptureButton(self)
UIView.animate(withDuration: 0.2) {
self.innerButtonSizeConstraints.forEach { $0.constant = self.recordingDiameter }
self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.recordingDiameter }
self.superview?.layoutIfNeeded()
}
case .changed:
guard let referenceHeight = delegate?.zoomScaleReferenceHeight else {
owsFailDebug("referenceHeight was unexpectedly nil")
return
}
guard referenceHeight > 0 else {
owsFailDebug("referenceHeight was unexpectedly <= 0")
return
}
guard let initialTouchLocation = initialTouchLocation else {
owsFailDebug("initialTouchLocation was unexpectedly nil")
return
}
let currentLocation = gesture.location(in: gestureView)
let minDistanceBeforeActivatingZoom: CGFloat = 30
let distance = initialTouchLocation.y - currentLocation.y - minDistanceBeforeActivatingZoom
let distanceForFullZoom = referenceHeight / 4
let ratio = distance / distanceForFullZoom
let alpha = ratio.clamp(0, 1)
Logger.verbose("distance: \(distance), alpha: \(alpha)")
let zoomIndicatorDiameter = CGFloatLerp(recordingDiameter, 3, alpha)
self.zoomIndicatorSizeConstraints.forEach { $0.constant = zoomIndicatorDiameter }
zoomIndicator.superview?.layoutIfNeeded()
delegate?.longPressCaptureButton(self, didUpdateZoomAlpha: alpha)
case .ended:
UIView.animate(withDuration: 0.2) {
self.innerButtonSizeConstraints.forEach { $0.constant = self.defaultDiameter }
self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.defaultDiameter }
self.superview?.layoutIfNeeded()
}
delegate?.didCompleteLongPressCaptureButton(self)
case .cancelled, .failed:
UIView.animate(withDuration: 0.2) {
self.innerButtonSizeConstraints.forEach { $0.constant = self.defaultDiameter }
self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.defaultDiameter }
self.superview?.layoutIfNeeded()
}
delegate?.didCancelLongPressCaptureButton(self)
default: break
}
}
}
class CapturePreviewView: UIView {
let previewLayer: AVCaptureVideoPreviewLayer
override var bounds: CGRect {
didSet {
previewLayer.frame = bounds
}
}
init(session: AVCaptureSession) {
previewLayer = AVCaptureVideoPreviewLayer(session: session)
super.init(frame: .zero)
self.contentMode = .scaleAspectFill
previewLayer.frame = bounds
layer.addSublayer(previewLayer)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class RecordingTimerView: UIView {
let stackViewSpacing: CGFloat = 4
override init(frame: CGRect) {
super.init(frame: frame)
let stackView = UIStackView(arrangedSubviews: [icon, label])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = stackViewSpacing
addSubview(stackView)
stackView.autoPinEdgesToSuperviewMargins()
updateView()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Subviews
private lazy var label: UILabel = {
let label = UILabel()
label.font = UIFont.ows_monospacedDigitFont(withSize: 20)
label.textAlignment = .center
label.textColor = UIColor.white
label.layer.shadowOffset = CGSize.zero
label.layer.shadowOpacity = 0.35
label.layer.shadowRadius = 4
return label
}()
static let iconWidth: CGFloat = 6
private let icon: UIView = {
let icon = CircleView()
icon.layer.shadowOffset = CGSize.zero
icon.layer.shadowOpacity = 0.35
icon.layer.shadowRadius = 4
icon.backgroundColor = .red
icon.autoSetDimensions(to: CGSize(width: iconWidth, height: iconWidth))
icon.alpha = 0
return icon
}()
// MARK: - Overrides //
override func sizeThatFits(_ size: CGSize) -> CGSize {
if #available(iOS 10, *) {
return super.sizeThatFits(size)
} else {
// iOS9 manual layout sizing required for items in the navigation bar
var baseSize = label.frame.size
baseSize.width = baseSize.width + stackViewSpacing + RecordingTimerView.iconWidth + layoutMargins.left + layoutMargins.right
baseSize.height = baseSize.height + layoutMargins.top + layoutMargins.bottom
return baseSize
}
}
// MARK: -
var recordingStartTime: TimeInterval?
func startCounting() {
recordingStartTime = CACurrentMediaTime()
timer = Timer.weakScheduledTimer(withTimeInterval: 0.1, target: self, selector: #selector(updateView), userInfo: nil, repeats: true)
UIView.animate(withDuration: 0.5,
delay: 0,
options: [.autoreverse, .repeat],
animations: { self.icon.alpha = 1 })
}
func stopCounting() {
timer?.invalidate()
timer = nil
icon.layer.removeAllAnimations()
UIView.animate(withDuration: 0.4) {
self.icon.alpha = 0
}
}
// MARK: -
private var timer: Timer?
private lazy var timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "mm:ss"
formatter.timeZone = TimeZone(identifier: "UTC")!
return formatter
}()
// This method should only be called when the call state is "connected".
var recordingDuration: TimeInterval {
guard let recordingStartTime = recordingStartTime else {
return 0
}
return CACurrentMediaTime() - recordingStartTime
}
@objc
private func updateView() {
let recordingDuration = self.recordingDuration
Logger.verbose("recordingDuration: \(recordingDuration)")
let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration)
label.text = timeFormatter.string(from: durationDate)
if #available(iOS 10, *) {
// do nothing
} else {
label.sizeToFit()
}
}
}