diff --git a/CameraKage/.gitignore b/CameraKage/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/CameraKage/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/CameraKage/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/CameraKage/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/CameraKage/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/CameraKage/Package.swift b/CameraKage/Package.swift new file mode 100644 index 0000000..e54e9b4 --- /dev/null +++ b/CameraKage/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "CameraKage", + platforms: [ + .iOS(.v15) + ], + products: [ + .library( + name: "CameraKage", + targets: ["CameraKage"]), + ], + targets: [ + .target( + name: "CameraKage", + path: "Sources"), + .testTarget( + name: "CameraKageTests", + dependencies: ["CameraKage"]), + ] +) diff --git a/CameraKage/README.md b/CameraKage/README.md new file mode 100644 index 0000000..e4aa457 --- /dev/null +++ b/CameraKage/README.md @@ -0,0 +1,3 @@ +# CameraKage + +A description of this package. diff --git a/CameraKage/Sources/CameraKage/CameraKage.swift b/CameraKage/Sources/CameraKage/CameraKage.swift new file mode 100644 index 0000000..fd6335c --- /dev/null +++ b/CameraKage/Sources/CameraKage/CameraKage.swift @@ -0,0 +1,305 @@ +import UIKit +import AVFoundation + +/// The main interface to use the `CameraKage` camera features. +public class CameraKage: UIView { + private var sessionComposer: SessionComposable = SessionComposer() + private let sessionQueue = DispatchQueue(label: "LA.cameraKage.sessionQueue") + private let permissionManager: PermissionsManagerProtocol = PermissionsManager() + private var cameraComponent: CameraComponent! + private let delegates: NSHashTable = NSHashTable.weakObjects() + + /// Determines if the `AVCaptureSession` of `CameraKage` is running. + public var isSessionRunning: Bool { sessionComposer.isSessionRunning } + + /// Determines if `CameraKage` has a video recording in progress. + public private(set) var isRecording: Bool = false + + /** + Register a listener for the `CameraKage` to receive notifications regarding the camera session. + + - parameter delegate: The object that will receive the notifications. + */ + public func registerDelegate(_ delegate: CameraKageDelegate) { + delegates.add(delegate as AnyObject) + } + + /** + Unregisters a listener from receiving `CameraKage` notifications. + + - parameter delegate: The object to be removed. + */ + public func unregisterDelegate(_ delegate: CameraKageDelegate) { + delegates.remove(delegate as AnyObject) + } + + /** + Prompts the user with the system alert to grant permission for the camera usage. + + - returns: Returns asynchronously a `Bool` specifying if the access was granted or not. + + - important: Info.plist key `NSCameraUsageDescription` must be set otherwise the application will crash. + */ + public func requestCameraPermission() async -> Bool { + await permissionManager.requestAccess(for: .video) + } + + /** + Prompts the user with the system alert to grant permission for the camera usage. + + - parameter completion: Callback containing a `Bool` result specifying if access was granted or not. + + - important: Info.plist key `NSCameraUsageDescription` must be set otherwise the application will crash. + */ + public func requestCameraPermission(completion: @escaping((Bool) -> Void)) { + permissionManager.requestAccess(for: .video, completion: completion) + } + + /** + Prompts the user with the system alert to grant permission for the microphone usage. + + - returns: Returns asynchronously a `Bool` specifying if the access was granted or not. + + - important: Info.plist key `NSMicrophoneUsageDescription` must be set otherwise the application will crash. + */ + public func requestMicrophonePermission() async -> Bool { + await permissionManager.requestAccess(for: .audio) + } + + /** + Prompts the user with the system alert to grant permission for the microphone usage. + + - parameter completion: Completion containing `Bool` result specifying if access was granted or not. + + - important: Info.plist key `NSMicrophoneUsageDescription` must be set otherwise the application will crash. + */ + public func requestMicrophonePermission(completion: @escaping((Bool) -> Void)) { + permissionManager.requestAccess(for: .audio, completion: completion) + } + + /** + Checks the current camera permission status. + + - returns: Returns the current status. + + - important: `getCameraPermissionStatus()` won't request access to the user. Use `requestCameraPermission()` to prompt the system alert. + */ + public func getCameraPermissionStatus() -> PermissionStatus { + permissionManager.getAuthorizationStatus(for: .video) + } + + /** + Checks the current microphone permission status. + + - returns: Returns the current status. + + - important: `getMicrophonePermissionStatus()` won't request access to the user. Use `requestMicrophonePermission()` to prompt the system alert. + */ + public func getMicrophonePermissionStatus() -> PermissionStatus { + permissionManager.getAuthorizationStatus(for: .audio) + } + + /** + Starts a discovery session to get the available camera devices for the client's phone. + + - returns: Returns the list of available `AVCaptureDevice`. + */ + public func getSupportedCameraDevices() -> [AVCaptureDevice] { + let discoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [ + AVCaptureDevice.DeviceType.builtInWideAngleCamera, + AVCaptureDevice.DeviceType.builtInUltraWideCamera, + AVCaptureDevice.DeviceType.builtInTelephotoCamera, + AVCaptureDevice.DeviceType.builtInDualCamera, + AVCaptureDevice.DeviceType.builtInDualWideCamera, + AVCaptureDevice.DeviceType.builtInTripleCamera, + AVCaptureDevice.DeviceType.builtInTrueDepthCamera + ], + mediaType: .video, + position: .unspecified) + return discoverySession.devices + } + + /** + Starts the camera session. + + - parameter options: Options used for the camera setup + + - important: Before calling `startCameraSession`, `requestCameraPermission()` and `requestMicrophonePermission()` methods can be called for custom UI usage. If permission requests aren't used, the system will call the alerts automatically. + */ + public func startCameraSession(with options: CameraComponentParsedOptions = CameraComponentParsedOptions(nil)) { + setupCameraComponent(with: options) + setupSessionDelegate() + sessionQueue.async { [weak self] in + guard let self else { return } + sessionComposer.startSession() + } + } + + /** + Stops the camera session and destroys the camera component. + */ + public func stopCameraSession() { + destroyCameraComponent() + sessionQueue.async { [weak self] in + guard let self else { return } + sessionComposer.stopSession() + } + } + + /** + Captures a photo from the camera. Resulted photo will be delivered via `CameraKageDelegate`. + + - parameter flashOption: Indicates what flash option should be used when capturing the photo. Default is `.off`. + - parameter redEyeCorrection: Determines if red eye correction should be applied or not. Default is `true`. + */ + public func capturePhoto(_ flashOption: AVCaptureDevice.FlashMode = .off, + redEyeCorrection: Bool = true) { + sessionQueue.async { [weak self] in + guard let self else { return } + cameraComponent.capturePhoto(flashOption, redEyeCorrection: redEyeCorrection) + } + } + + /** + Starts a video recording for the camera. `CameraKageDelegate` sends a notification when the recording has started. + */ + public func startVideoRecording() { + sessionQueue.async { [weak self] in + guard let self, !isRecording else { return } + isRecording = true + cameraComponent.startMovieRecording() + } + } + + /** + Stops the video recording. `CameraKageDelegate` sends a notification containing the URL where the video file is stored. + */ + public func stopVideoRecording() { + sessionQueue.async { [weak self] in + guard let self, isRecording else { return } + isRecording = false + cameraComponent.stopMovieRecording() + } + } + + /** + Flips the camera from back to front and vice-versa. + + - important: Camera can't be flipped while recording a video. Session is restarted when flipping the camera. + */ + public func flipCamera() { + sessionQueue.async { [weak self] in + guard let self, !isRecording else { return } + sessionComposer.pauseSession() + cameraComponent.flipCamera() + sessionComposer.resumeSession() + } + } + + /** + Adjusts the focus and the exposure of the camera. + + - parameter focusMode: Focus mode of the camera. Default is `.autoFocus`. + - parameter exposureMode: Exposure mode of the camera. Default is `.autoExpose`. + - parameter devicePoint: The point of the camera where the focus should be switched to. + - parameter monitorSubjectAreaChange: If set `true`, it registers the camera to receive notifications about area changes for the user to re-focus if needed. Default is `true`. + */ + public func adjustFocusAndExposure(with focusMode: AVCaptureDevice.FocusMode = .autoFocus, + exposureMode: AVCaptureDevice.ExposureMode = .autoExpose, + at devicePoint: CGPoint, + monitorSubjectAreaChange: Bool = true) { + sessionQueue.async { [weak self] in + guard let self else { return } + cameraComponent.focus(with: focusMode, + exposureMode: exposureMode, + at: devicePoint, + monitorSubjectAreaChange: monitorSubjectAreaChange) + } + } + + private func setupCameraComponent(with options: CameraComponentParsedOptions) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + cameraComponent = CameraComponent(sessionComposer: sessionComposer, + options: options, + delegate: self) + addSubview(cameraComponent) + cameraComponent.layoutToFill(inView: self) + cameraComponent.configureSession() + } + } + + private func destroyCameraComponent() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + cameraComponent.removeObserver() + cameraComponent.removeFromSuperview() + cameraComponent = nil + } + } + + private func setupSessionDelegate() { + sessionComposer.onSessionStart = { [weak self] in + guard let self else { return } + invokeDelegates { $0.cameraSessionDidStart(self) } + } + + sessionComposer.onSessionStop = { [weak self] in + guard let self else { return } + invokeDelegates { $0.cameraSessionDidStop(self) } + } + + sessionComposer.onSessionInterruption = { [weak self] reason in + guard let self else { return } + invokeDelegates { $0.camera(self, sessionWasInterrupted: reason) } + } + + sessionComposer.onSessionInterruptionEnd = { [weak self] in + guard let self else { return } + invokeDelegates { $0.cameraSessionInterruptionEnded(self) } + } + + sessionComposer.onSessionReceiveRuntimeError = { [weak self] isRestartable, avError in + guard let self else { return } + if isRestartable { + sessionQueue.async { [weak self] in + guard let self else { return } + sessionComposer.resumeSession() + } + } + let sessionError = CameraError.CameraSessionErrorReason.runtimeError(avError) + invokeDelegates { $0.camera(self, didEncounterError: .cameraSessionError(reason: sessionError))} + } + + sessionComposer.onDeviceSubjectAreaChange = { [weak self] in + guard let self else { return } + invokeDelegates { $0.cameraDeviceDidChangeSubjectArea(self) } + } + } + + private func invokeDelegates(_ execute: (CameraKageDelegate) -> Void) { + delegates.allObjects.forEach { delegate in + guard let delegate = delegate as? CameraKageDelegate else { return } + execute(delegate) + } + } +} + +// MARK: - CameraComponentDelegate +extension CameraKage: CameraComponentDelegate { + func cameraComponent(_ cameraComponent: CameraComponent, didCapturePhoto photo: Data) { + invokeDelegates { $0.camera(self, didOutputPhotoWithData: photo)} + } + + func cameraComponent(_ cameraComponent: CameraComponent, didStartRecordingVideo atFileURL: URL) { + invokeDelegates { $0.camera(self, didStartRecordingVideoAtFileURL: atFileURL)} + } + + func cameraComponent(_ cameraComponent: CameraComponent, didRecordVideo videoURL: URL) { + invokeDelegates { $0.camera(self, didOutputVideoAtFileURL: videoURL)} + } + + func cameraComponent(_ cameraComponent: CameraComponent, didFail withError: CameraError) { + invokeDelegates { $0.camera(self, didEncounterError: withError) } + } +} diff --git a/CameraKage/Sources/CameraKage/Extensions/UIView/UIView+LayoutToFill.swift b/CameraKage/Sources/CameraKage/Extensions/UIView/UIView+LayoutToFill.swift new file mode 100644 index 0000000..7300afb --- /dev/null +++ b/CameraKage/Sources/CameraKage/Extensions/UIView/UIView+LayoutToFill.swift @@ -0,0 +1,18 @@ +// +// UIView+LayoutToFill.swift +// CameraKage +// +// Created by Lobont Andrei on 21.05.2023. +// + +import UIKit + +extension UIView { + func layoutToFill(inView: UIView) { + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([topAnchor.constraint(equalTo: inView.topAnchor), + leadingAnchor.constraint(equalTo: inView.leadingAnchor), + bottomAnchor.constraint(equalTo: inView.bottomAnchor), + trailingAnchor.constraint(equalTo: inView.trailingAnchor)]) + } +} diff --git a/CameraKage/Sources/CameraKage/Extensions/URL/URL+TemporaryURL.swift b/CameraKage/Sources/CameraKage/Extensions/URL/URL+TemporaryURL.swift new file mode 100644 index 0000000..f9c6070 --- /dev/null +++ b/CameraKage/Sources/CameraKage/Extensions/URL/URL+TemporaryURL.swift @@ -0,0 +1,18 @@ +// +// URL+TemporaryURL.swift +// CameraKage +// +// Created by Lobont Andrei on 22.05.2023. +// + +import Foundation + +extension URL { + static func makeTempUrl(for type: MediaType) -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + switch type { + case .photo: return url.appendingPathExtension("jpg") + case .video: return url.appendingPathExtension("mov") + } + } +} diff --git a/CameraKage/Sources/CameraKage/General/CameraComponentOptions.swift b/CameraKage/Sources/CameraKage/General/CameraComponentOptions.swift new file mode 100644 index 0000000..303fba0 --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/CameraComponentOptions.swift @@ -0,0 +1,99 @@ +// +// CameraComponentOptions.swift +// CameraKage +// +// Created by Lobont Andrei on 11.05.2023. +// + +import AVFoundation + +public typealias CameraComponentOptions = [CameraComponentOptionItem] + +public enum CameraComponentOptionItem { + /// Quality prioritization mode for the photo output. + /// Constants indicating how photo quality should be prioritized against speed. + /// Default is `.balanced`. + case photoQualityPrioritizationMode(AVCapturePhotoOutput.QualityPrioritization) + + /// The mode of the video stabilization. + /// Default is `.auto`. + case videoStabilizationMode(AVCaptureVideoStabilizationMode) + + /// The orientation setting of the camera. + /// Default is `.portrait`. + case cameraOrientation(AVCaptureVideoOrientation) + + /// The type of camera to be used on `CameraComponent`. + /// Default is `.builtInWideAngleCamera`. + case deviceType(AVCaptureDevice.DeviceType) + + /// The position of the device. + /// Default is `.back`. + case devicePosition(AVCaptureDevice.Position) + + /// Will define how the layer will display the player's visual content. + /// Default is `.resizeAspectFill`. + case videoGravity(AVLayerVideoGravity) + + /// Maximum duration allowed for video recordings. + /// Default is `.positiveInfinity`. + case maxVideoDuration(CMTime) + + /// Indicates if the `CameraComponent` has pinch to zoom. + /// Each `CameraComponent` can have its own zoom setting. + /// Default is `false`. + case pinchToZoomEnabled(Bool) + + /// The minimum zoom scale of the `CameraComponent`. + /// Each `CameraComponent` can have its own minimum scale. + /// Default is `1.0`. + case minimumZoomScale(CGFloat) + + /// The maximum zoom scale of the `CameraComponent`. + /// Each `CameraComponent` can have its own maximum scale. + /// Default is `5.0`. + case maximumZoomScale(CGFloat) +} + +/// Options used for the output settings of the camera component. +/// These should be set before the `startCameraSession()` is called. +public struct CameraComponentParsedOptions { + public var photoQualityPrioritizationMode: AVCapturePhotoOutput.QualityPrioritization = .balanced + public var videoStabilizationMode: AVCaptureVideoStabilizationMode = .auto + public var cameraOrientation: AVCaptureVideoOrientation = .portrait + public var deviceType: AVCaptureDevice.DeviceType = .builtInWideAngleCamera + public var devicePosition: AVCaptureDevice.Position = .back + public var videoGravity: AVLayerVideoGravity = .resizeAspectFill + public var maxVideoDuration: CMTime = .positiveInfinity + public var pinchToZoomEnabled: Bool = false + public var minimumZoomScale: CGFloat = 1.0 + public var maximumZoomScale: CGFloat = 5.0 + + public init(_ options: CameraComponentOptions?) { + guard let options else { return } + options.forEach { + switch $0 { + case .photoQualityPrioritizationMode(let photoQualityPrioritizationMode): + self.photoQualityPrioritizationMode = photoQualityPrioritizationMode + case .videoStabilizationMode(let videoStabilizationMode): + self.videoStabilizationMode = videoStabilizationMode + case .cameraOrientation(let cameraOrientation): + self.cameraOrientation = cameraOrientation + case .deviceType(let deviceType): + self.deviceType = deviceType + case .devicePosition(let devicePosition): + self.devicePosition = devicePosition + case .videoGravity(let videoGravity): + self.videoGravity = videoGravity + case .maxVideoDuration(let maxVideoDuration): + self.maxVideoDuration = maxVideoDuration + case .pinchToZoomEnabled(let pinchToZoomEnabled): + self.pinchToZoomEnabled = pinchToZoomEnabled + case .minimumZoomScale(let minimumZoomScale): + self.minimumZoomScale = minimumZoomScale + case .maximumZoomScale(let maximumZoomScale): + self.maximumZoomScale = maximumZoomScale + } + } + } +} diff --git a/CameraKage/Sources/CameraKage/General/CameraError.swift b/CameraKage/Sources/CameraKage/General/CameraError.swift new file mode 100644 index 0000000..d198014 --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/CameraError.swift @@ -0,0 +1,50 @@ +// +// CameraError.swift +// CameraKage +// +// Created by Lobont Andrei on 22.05.2023. +// + +import AVFoundation + +public enum CameraError: Error { + public enum CameraComponentErrorReason { + /// Specified video device couldn't be added to the session. *device might not be supported by the phone* + case failedToConfigureVideoDevice + + /// Audio device couldn't be added to the session. + case failedToConfigureAudioDevice + + /// Photo output couldn't be added to the session. + case failedToAddPhotoOutput + + /// Movie output couldn't be added to the session. + case failedToAddMovieOutput + + /// The preview layer wasn't connected to the camera session. + case failedToAddPreviewLayer + + /// Couldn't lock video device for further configurations. + case failedToLockDevice + + /// File manager couldn't remove a corrupted video file. + case failedToRemoveFileManagerItem + + /// Camera was shutdown due to high pressure level. + case pressureLevelShutdown + + /// Photo capture failure, error message received from the delegate will be passed. + case failedToOutputPhoto(message: String?) + + /// Movie capture failure, error message received from the delegate will be passed. + case failedToOutputMovie(message: String?) + } + + public enum CameraSessionErrorReason { + /// An internal error was encountered by the capture session. The specific AVError is passed. + case runtimeError(AVError) + } + + case cameraComponentError(reason: CameraComponentErrorReason) + case cameraSessionError(reason: CameraSessionErrorReason) +} diff --git a/CameraKage/Sources/CameraKage/General/CameraKageDelegate.swift b/CameraKage/Sources/CameraKage/General/CameraKageDelegate.swift new file mode 100644 index 0000000..6a876d2 --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/CameraKageDelegate.swift @@ -0,0 +1,93 @@ +// +// CameraKageDelegate.swift +// CameraKage +// +// Created by Lobont Andrei on 24.05.2023. +// + +import Foundation + +/// Delegate protocol used to notify events and pass information in regards to them. +public protocol CameraKageDelegate: AnyObject { + /** + Called when the camera has outputted a photo. + + - parameter camera: The camera composer which is sending the event. + - parameter data: The data representation of the photo. + */ + func camera(_ camera: CameraKage, didOutputPhotoWithData data: Data) + + /** + Called when the camera has started a video recording. + + - parameter camera: The camera composer which is sending the event. + - parameter url: The file location where the video will be stored when recording ends. + */ + func camera(_ camera: CameraKage, didStartRecordingVideoAtFileURL url: URL) + + /** + Called when the camera has outputted a video recording. + + - parameter camera: The camera composer which is sending the event. + - parameter url: The file location where the video is stored. + */ + func camera(_ camera: CameraKage, didOutputVideoAtFileURL url: URL) + + /** + Called when the camera composer encountered an error. Could be an output, camera or a session related error. + + - parameter camera: The camera composer which is sending the event. + - parameter error: The error that was encountered. + */ + func camera(_ camera: CameraKage, didEncounterError error: CameraError) + + /** + Called when the camera session was interrupted. This can happen from various reason but most common ones would be phone calls while using the camera, other apps taking control over the phone camera or app moving to background. + + - parameter camera: The camera composer which is sending the event. + - parameter reason: The reason for the session interruption. + + - important: When this is called, the camera will freezee so some UI overlay might be necessary on the client side. + */ + func camera(_ camera: CameraKage, sessionWasInterrupted reason: SessionInterruptionReason) + + /** + Called when the camera session interruption has ended. When this is called the camera will resume working. + + - parameter camera: The camera composer which is sending the event. + */ + func cameraSessionInterruptionEnded(_ camera: CameraKage) + + /** + Called when the camera session was started and the actual camera will be visible on screen. + + - parameter camera: The camera composer which is sending the event. + */ + func cameraSessionDidStart(_ camera: CameraKage) + + /** + Called when the camera session has stopped. + + - parameter camera: The camera composer which is sending the event. + */ + func cameraSessionDidStop(_ camera: CameraKage) + + /** + Posted when the instance of AVCaptureDevice has detected a substantial change to the video subject area. This notification is only sent if you first set monitorSubjectAreaChange to `true` in the `focus()` camera method. + + - parameter camera: The camera composer which is sending the event. + */ + func cameraDeviceDidChangeSubjectArea(_ camera: CameraKage) +} + +public extension CameraKageDelegate { + func camera(_ camera: CameraKage, didOutputPhotoWithData data: Data) {} + func camera(_ camera: CameraKage, didStartRecordingVideoAtFileURL url: URL) {} + func camera(_ camera: CameraKage, didOutputVideoAtFileURL url: URL) {} + func camera(_ camera: CameraKage, didEncounterError error: CameraError) {} + func camera(_ camera: CameraKage, sessionWasInterrupted reason: SessionInterruptionReason) {} + func cameraSessionInterruptionEnded(_ camera: CameraKage) {} + func cameraSessionDidStart(_ camera: CameraKage) {} + func cameraSessionDidStop(_ camera: CameraKage) {} + func cameraDeviceDidChangeSubjectArea(_ camera: CameraKage) {} +} diff --git a/CameraKage/Sources/CameraKage/General/MediaType.swift b/CameraKage/Sources/CameraKage/General/MediaType.swift new file mode 100644 index 0000000..b8e3b1d --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/MediaType.swift @@ -0,0 +1,13 @@ +// +// MediaType.swift +// CameraKage +// +// Created by Lobont Andrei on 22.05.2023. +// + +import Foundation + +enum MediaType { + case photo + case video +} diff --git a/CameraKage/Sources/CameraKage/General/PermissionStatus.swift b/CameraKage/Sources/CameraKage/General/PermissionStatus.swift new file mode 100644 index 0000000..c46dc2d --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/PermissionStatus.swift @@ -0,0 +1,14 @@ +// +// PermissionStatus.swift +// CameraKage +// +// Created by Lobont Andrei on 23.05.2023. +// + +import Foundation + +public enum PermissionStatus { + case authorized + case denied + case notDetermined +} diff --git a/CameraKage/Sources/CameraKage/General/PermissionsManager.swift b/CameraKage/Sources/CameraKage/General/PermissionsManager.swift new file mode 100644 index 0000000..04e9d1d --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/PermissionsManager.swift @@ -0,0 +1,46 @@ +// +// PermissionsManager.swift +// CameraKage +// +// Created by Lobont Andrei on 23.05.2023. +// + +import AVFoundation + +protocol PermissionsManagerProtocol { + func getAuthorizationStatus(for media: AVMediaType) -> PermissionStatus + func requestAccess(for media: AVMediaType) async -> Bool + func requestAccess(for media: AVMediaType, completion: @escaping((Bool) -> Void)) +} + +final class PermissionsManager: PermissionsManagerProtocol { + func getAuthorizationStatus(for media: AVMediaType) -> PermissionStatus { + switch AVCaptureDevice.authorizationStatus(for: media) { + case .notDetermined: return .notDetermined + case .denied: return .denied + case .authorized: return .authorized + default: return .denied + } + } + + func requestAccess(for media: AVMediaType) async -> Bool { + let status = getAuthorizationStatus(for: media) + var isAuthorized = status == .authorized + if status == .notDetermined { + isAuthorized = await AVCaptureDevice.requestAccess(for: media) + } + return isAuthorized + } + + func requestAccess(for media: AVMediaType, completion: @escaping((Bool) -> Void)) { + let status = getAuthorizationStatus(for: media) + let isAuthorized = status == .authorized + if status == .notDetermined { + AVCaptureDevice.requestAccess(for: media) { granted in + completion(granted) + } + } else { + completion(isAuthorized) + } + } +} diff --git a/CameraKage/Sources/CameraKage/General/Session/SessionComposable.swift b/CameraKage/Sources/CameraKage/General/Session/SessionComposable.swift new file mode 100644 index 0000000..02f00f2 --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/Session/SessionComposable.swift @@ -0,0 +1,36 @@ +// +// SessionComposable.swift +// CameraKage +// +// Created by Lobont Andrei on 26.05.2023. +// + +import AVFoundation + +protocol SessionComposable { + var isSessionRunning: Bool { get } + var outputs: [AVCaptureOutput] { get } + var inputs: [AVCaptureInput] { get } + var onSessionReceiveRuntimeError: ((Bool, AVError) -> Void)? { get set } + var onSessionStart: (() -> Void)? { get set } + var onSessionStop: (() -> Void)? { get set } + var onSessionInterruption: ((SessionInterruptionReason) -> Void)? { get set } + var onSessionInterruptionEnd: (() -> Void)? { get set } + var onDeviceSubjectAreaChange: (() -> Void)? { get set } + + func beginConfiguration() + func commitConfiguration() + func canAddInput(_ input: AVCaptureInput) -> Bool + func addInput(_ input: AVCaptureInput) + func addInputWithNoConnections(_ input: AVCaptureInput) + func canAddOutput(_ output: AVCaptureOutput) -> Bool + func addOutput(_ output: AVCaptureOutput) + func canAddConnection(_ connection: AVCaptureConnection) -> Bool + func addConnection(_ connection: AVCaptureConnection) + func connectPreviewLayer(_ previewLayer: AVCaptureVideoPreviewLayer) + func cleanupSession() + func startSession() + func stopSession() + func pauseSession() + func resumeSession() +} diff --git a/CameraKage/Sources/CameraKage/General/Session/SessionComposer.swift b/CameraKage/Sources/CameraKage/General/Session/SessionComposer.swift new file mode 100644 index 0000000..11366ae --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/Session/SessionComposer.swift @@ -0,0 +1,101 @@ +// +// SessionComposer.swift +// CameraKage +// +// Created by Lobont Andrei on 26.05.2023. +// + +import AVFoundation + +final class SessionComposer: SessionComposable { + private let session: AVCaptureMultiCamSession + private let sessionDelegate: SessionDelegate + + var isSessionRunning: Bool { session.isRunning } + var outputs: [AVCaptureOutput] { session.outputs } + var inputs: [AVCaptureInput] { session.inputs } + var onSessionReceiveRuntimeError: ((Bool, AVError) -> Void)? + var onSessionStart: (() -> Void)? + var onSessionStop: (() -> Void)? + var onSessionInterruption: ((SessionInterruptionReason) -> Void)? + var onSessionInterruptionEnd: (() -> Void)? + var onDeviceSubjectAreaChange: (() -> Void)? + + init() { + self.session = AVCaptureMultiCamSession() + self.sessionDelegate = SessionDelegate(session: session) + } + + func beginConfiguration() { + session.beginConfiguration() + } + + func commitConfiguration() { + session.commitConfiguration() + } + + func canAddInput(_ input: AVCaptureInput) -> Bool { + session.canAddInput(input) + } + + func addInput(_ input: AVCaptureInput) { + session.addInput(input) + } + + func addInputWithNoConnections(_ input: AVCaptureInput) { + session.addInputWithNoConnections(input) + } + + func canAddOutput(_ output: AVCaptureOutput) -> Bool { + session.canAddOutput(output) + } + + func addOutput(_ output: AVCaptureOutput) { + session.addOutputWithNoConnections(output) + } + + func canAddConnection(_ connection: AVCaptureConnection) -> Bool { + session.canAddConnection(connection) + } + + func addConnection(_ connection: AVCaptureConnection) { + session.addConnection(connection) + } + + func connectPreviewLayer(_ previewLayer: AVCaptureVideoPreviewLayer) { + previewLayer.setSessionWithNoConnection(session) + } + + func cleanupSession() { + session.outputs.forEach { self.session.removeOutput($0) } + session.inputs.forEach { self.session.removeInput($0) } + } + + func startSession() { + setupDelegate() + sessionDelegate.addObservers() + session.startRunning() + } + + func stopSession() { + session.stopRunning() + sessionDelegate.removeObservers() + } + + func pauseSession() { + session.stopRunning() + } + + func resumeSession() { + session.startRunning() + } + + private func setupDelegate() { + sessionDelegate.onSessionStart = onSessionStart + sessionDelegate.onSessionStop = onSessionStop + sessionDelegate.onSessionInterruption = onSessionInterruption + sessionDelegate.onSessionInterruptionEnd = onSessionInterruptionEnd + sessionDelegate.onReceiveRuntimeError = onSessionReceiveRuntimeError + sessionDelegate.onDeviceSubjectAreaChange = onDeviceSubjectAreaChange + } +} diff --git a/CameraKage/Sources/CameraKage/General/Session/SessionDelegate.swift b/CameraKage/Sources/CameraKage/General/Session/SessionDelegate.swift new file mode 100644 index 0000000..f0f7590 --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/Session/SessionDelegate.swift @@ -0,0 +1,99 @@ +// +// SessionDelegate.swift +// CameraKage +// +// Created by Lobont Andrei on 23.05.2023. +// + +import AVFoundation + +final class SessionDelegate { + private let session: AVCaptureMultiCamSession + + var onReceiveRuntimeError: ((Bool, AVError) -> Void)? + var onSessionStart: (() -> Void)? + var onSessionStop: (() -> Void)? + var onSessionInterruption: ((SessionInterruptionReason) -> Void)? + var onSessionInterruptionEnd: (() -> Void)? + var onDeviceSubjectAreaChange: (() -> Void)? + + init(session: AVCaptureMultiCamSession) { + self.session = session + } + + func addObservers() { + NotificationCenter.default.addObserver(self, + selector: #selector(sessionRuntimeError), + name: .AVCaptureSessionRuntimeError, + object: session) + NotificationCenter.default.addObserver(self, + selector: #selector(sessionWasInterrupted), + name: .AVCaptureSessionWasInterrupted, + object: session) + NotificationCenter.default.addObserver(self, + selector: #selector(sessionInterruptionEnded), + name: .AVCaptureSessionInterruptionEnded, + object: session) + NotificationCenter.default.addObserver(self, + selector: #selector(sessionDidStartRunning), + name: .AVCaptureSessionDidStartRunning, + object: session) + NotificationCenter.default.addObserver(self, + selector: #selector(sessionDidStopRunning), + name: .AVCaptureSessionDidStopRunning, + object: session) + NotificationCenter.default.addObserver(self, + selector: #selector(deviceSubjectAreaDidChange), + name: .AVCaptureDeviceSubjectAreaDidChange, + object: session) + } + + func removeObservers() { + NotificationCenter.default.removeObserver(self) + } + + @objc private func sessionRuntimeError(notification: NSNotification) { + guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else { return } + onReceiveRuntimeError?(error.code == .mediaServicesWereReset, error) + } + + @objc private func sessionDidStartRunning(notification: NSNotification) { + onSessionStart?() + } + + @objc private func sessionDidStopRunning(notification: NSNotification) { + onSessionStop?() + } + + /// This will be called anytime there is another app that tries to use the audio or video devices + /// Removing that device will unfreeze the camera but video will be corrupted (couldn't find a reason yet) + @objc private func sessionWasInterrupted(notification: NSNotification) { + guard let userInfoValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as AnyObject?, + let reasonIntegerValue = userInfoValue.integerValue, + let reason = AVCaptureSession.InterruptionReason(rawValue: reasonIntegerValue) else { return } + switch reason { + case .videoDeviceNotAvailableInBackground: + onSessionInterruption?(.videoDeviceNotAvailableInBackground) + case .audioDeviceInUseByAnotherClient: + onSessionInterruption?(.audioDeviceInUseByAnotherClient) + case .videoDeviceInUseByAnotherClient: + onSessionInterruption?(.videoDeviceInUseByAnotherClient) + case .videoDeviceNotAvailableWithMultipleForegroundApps: + onSessionInterruption?(.videoDeviceNotAvailableWithMultipleForegroundApps) + case .videoDeviceNotAvailableDueToSystemPressure: + onSessionInterruption?(.videoDeviceNotAvailableDueToSystemPressure) + @unknown default: + onSessionInterruption?(.unknown) + } + } + + /// This will be called anytime the problem with the session was solved + /// Entering in this method dosen't necessarly means that the call or the other application that invoked the problem was closed + @objc private func sessionInterruptionEnded(notification: NSNotification) { + onSessionInterruptionEnd?() + } + + @objc private func deviceSubjectAreaDidChange(notification: NSNotification) { + onDeviceSubjectAreaChange?() + } +} diff --git a/CameraKage/Sources/CameraKage/General/Session/SessionInterruptionReason.swift b/CameraKage/Sources/CameraKage/General/Session/SessionInterruptionReason.swift new file mode 100644 index 0000000..09665bd --- /dev/null +++ b/CameraKage/Sources/CameraKage/General/Session/SessionInterruptionReason.swift @@ -0,0 +1,28 @@ +// +// SessionInterruptionReason.swift +// CameraKage +// +// Created by Lobont Andrei on 24.05.2023. +// + +import Foundation + +public enum SessionInterruptionReason { + /// An unhandled case appeared. + case unknown + + /// An interruption caused by the app being sent to the background while using a camera. Camera usage is prohibited while in the background. Provided you don't explicitly call [session stopRunning], your -startRunning request is preserved, and when your app comes back to foreground, you receive AVCaptureSessionInterruptionEndedNotification and your session starts running. + case videoDeviceNotAvailableInBackground + + /// An interruption caused by the audio hardware temporarily being made unavailable, for instance, for a phone call, or alarm. + case audioDeviceInUseByAnotherClient + + /// An interruption caused by the video device temporarily being made unavailable, for instance, when stolen away by another AVCaptureSession. + case videoDeviceInUseByAnotherClient + + /// An interruption caused when the app is running in a multi-app layout, causing resource contention and degraded recording quality of service. Given your present AVCaptureSession configuration, the session may only be run if your app occupies the full screen. + case videoDeviceNotAvailableWithMultipleForegroundApps + + /// An interruption caused by the video device temporarily being made unavailable due to system pressure, such as thermal duress. + case videoDeviceNotAvailableDueToSystemPressure +} diff --git a/CameraKage/Sources/CameraKage/Views/CameraComponent.swift b/CameraKage/Sources/CameraKage/Views/CameraComponent.swift new file mode 100644 index 0000000..e1647b1 --- /dev/null +++ b/CameraKage/Sources/CameraKage/Views/CameraComponent.swift @@ -0,0 +1,376 @@ +// +// CameraComponent.swift +// CameraKage +// +// Created by Lobont Andrei on 10.05.2023. +// + +import UIKit +import AVFoundation + +protocol CameraComponentDelegate: AnyObject { + func cameraComponent(_ cameraComponent: CameraComponent, didCapturePhoto photo: Data) + func cameraComponent(_ cameraComponent: CameraComponent, didStartRecordingVideo atFileURL: URL) + func cameraComponent(_ cameraComponent: CameraComponent, didRecordVideo videoURL: URL) + func cameraComponent(_ cameraComponent: CameraComponent, didFail withError: CameraError) +} + +class CameraComponent: UIView { + private let sessionComposer: SessionComposable + private var options: CameraComponentParsedOptions + private let previewLayer = AVCaptureVideoPreviewLayer() + private var keyValueObservations = [NSKeyValueObservation]() + private let photoOutput = AVCapturePhotoOutput() + private var photoData: Data? + private var movieFileOutput = AVCaptureMovieFileOutput() + @objc dynamic private var videoDeviceInput: AVCaptureDeviceInput! + private var videoDevicePort: AVCaptureInput.Port! + private var audioDevicePort: AVCaptureInput.Port! + + private lazy var pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(pinch(_:))) + private var lastZoomFactor: CGFloat = 1.0 + + private weak var delegate: CameraComponentDelegate? + + init(sessionComposer: SessionComposable, + options: CameraComponentParsedOptions, + delegate: CameraComponentDelegate?) { + self.sessionComposer = sessionComposer + self.options = options + self.delegate = delegate + super.init(frame: .zero) + layer.addSublayer(previewLayer) + } + + required init?(coder: NSCoder) { + fatalError("Use session init") + } + + override func layoutSubviews() { + super.layoutSubviews() + previewLayer.bounds = frame + previewLayer.position = CGPoint(x: frame.width / 2, y: frame.height / 2) + } + + func capturePhoto(_ flashMode: AVCaptureDevice.FlashMode, + redEyeCorrection: Bool) { + var photoSettings = AVCapturePhotoSettings() + photoSettings.flashMode = flashMode + photoSettings.isAutoRedEyeReductionEnabled = redEyeCorrection + + if photoOutput.availablePhotoCodecTypes.contains(.hevc) { + photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) + } + if let previewPhotoPixelFormatType = photoSettings.availablePreviewPhotoPixelFormatTypes.first { + photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: previewPhotoPixelFormatType] + } + + photoOutput.capturePhoto(with: photoSettings, delegate: self) + } + + func startMovieRecording() { + guard !movieFileOutput.isRecording else { return } + movieFileOutput.startRecording(to: .makeTempUrl(for: .video), recordingDelegate: self) + } + + func stopMovieRecording() { + movieFileOutput.stopRecording() + } + + func flipCamera() { + options.devicePosition = options.devicePosition == .back ? .front : .back + removeObserver() + sessionComposer.cleanupSession() + configureSession() + addObserver() + } + + func focus(with focusMode: AVCaptureDevice.FocusMode, + exposureMode: AVCaptureDevice.ExposureMode, + at devicePoint: CGPoint, + monitorSubjectAreaChange: Bool) { + let point = previewLayer.captureDevicePointConverted(fromLayerPoint: devicePoint) + let device = videoDeviceInput.device + do { + try device.lockForConfiguration() + if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) { + device.focusPointOfInterest = point + device.focusMode = focusMode + } + if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) { + device.exposurePointOfInterest = point + device.exposureMode = exposureMode + } + device.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange + device.unlockForConfiguration() + } catch { + notifyDelegateForError(.failedToLockDevice) + } + } + + func configureSession() { + defer { + sessionComposer.commitConfiguration() + } + sessionComposer.beginConfiguration() + + configureVideoDevice() + configureAudioDevice() + configureMovieFileOutput() + configurePhotoOutput() + configurePreviewLayer() + configurePinchGesture() + addObserver() + } + + private func configureVideoDevice() { + do { + guard let videoDevice = AVCaptureDevice.default(options.deviceType, + for: .video, + position: options.devicePosition) else { + notifyDelegateForError(.failedToConfigureVideoDevice) + return + } + let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) + + guard sessionComposer.canAddInput(videoDeviceInput) else { return } + sessionComposer.addInputWithNoConnections(videoDeviceInput) + self.videoDeviceInput = videoDeviceInput + + guard let videoPort = videoDeviceInput.ports(for: .video, + sourceDeviceType: options.deviceType, + sourceDevicePosition: options.devicePosition).first else { + notifyDelegateForError(.failedToConfigureVideoDevice) + return + } + self.videoDevicePort = videoPort + } catch { + notifyDelegateForError(.failedToConfigureVideoDevice) + } + } + + private func configureAudioDevice() { + do { + guard let audioDevice = AVCaptureDevice.default(for: .audio) else { + notifyDelegateForError(.failedToConfigureAudioDevice) + return + } + let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice) + guard sessionComposer.canAddInput(audioDeviceInput) else { + notifyDelegateForError(.failedToConfigureAudioDevice) + return + } + sessionComposer.addInputWithNoConnections(audioDeviceInput) + guard let audioPort = audioDeviceInput.ports(for: .audio, + sourceDeviceType: .builtInMicrophone, + sourceDevicePosition: options.devicePosition).first else { + notifyDelegateForError(.failedToConfigureAudioDevice) + return + } + self.audioDevicePort = audioPort + } catch { + notifyDelegateForError(.failedToConfigureAudioDevice) + } + } + + private func configurePhotoOutput() { + guard sessionComposer.canAddOutput(photoOutput) else { + notifyDelegateForError(.failedToAddPhotoOutput) + return + } + sessionComposer.addOutput(photoOutput) + photoOutput.maxPhotoQualityPrioritization = options.photoQualityPrioritizationMode + + let photoConnection = AVCaptureConnection(inputPorts: [videoDevicePort], output: photoOutput) + guard sessionComposer.canAddConnection(photoConnection) else { + notifyDelegateForError(.failedToAddPhotoOutput) + return + } + sessionComposer.addConnection(photoConnection) + + photoConnection.videoOrientation = options.cameraOrientation + photoConnection.isVideoMirrored = videoDeviceInput.device.position == .front + } + + private func configureMovieFileOutput() { + guard sessionComposer.canAddOutput(movieFileOutput) else { + notifyDelegateForError(.failedToAddMovieOutput) + return + } + sessionComposer.addOutput(movieFileOutput) + movieFileOutput.maxRecordedDuration = options.maxVideoDuration + + let videoConnection = AVCaptureConnection(inputPorts: [videoDevicePort], output: movieFileOutput) + guard sessionComposer.canAddConnection(videoConnection) else { + notifyDelegateForError(.failedToAddMovieOutput) + return + } + sessionComposer.addConnection(videoConnection) + + videoConnection.isVideoMirrored = videoDeviceInput.device.position == .front + videoConnection.videoOrientation = options.cameraOrientation + if videoConnection.isVideoStabilizationSupported { + videoConnection.preferredVideoStabilizationMode = options.videoStabilizationMode + } + + let audioConnection = AVCaptureConnection(inputPorts: [audioDevicePort], output: movieFileOutput) + guard sessionComposer.canAddConnection(audioConnection) else { + notifyDelegateForError(.failedToAddMovieOutput) + return + } + sessionComposer.addConnection(audioConnection) + + let availableVideoCodecTypes = movieFileOutput.availableVideoCodecTypes + if availableVideoCodecTypes.contains(.hevc) { + movieFileOutput.setOutputSettings([AVVideoCodecKey: AVVideoCodecType.hevc], + for: videoConnection) + } + } + + private func configurePreviewLayer() { + sessionComposer.connectPreviewLayer(previewLayer) + previewLayer.videoGravity = options.videoGravity + let previewLayerConnection = AVCaptureConnection(inputPort: videoDevicePort, videoPreviewLayer: previewLayer) + previewLayerConnection.videoOrientation = options.cameraOrientation + guard sessionComposer.canAddConnection(previewLayerConnection) else { + notifyDelegateForError(.failedToAddPreviewLayer) + return + } + sessionComposer.addConnection(previewLayerConnection) + } + + private func configurePinchGesture() { + if options.pinchToZoomEnabled { + DispatchQueue.main.async { + self.addGestureRecognizer(self.pinchGestureRecognizer) + } + } + } + + private func notifyDelegateForError(_ error: CameraError.CameraComponentErrorReason) { + delegate?.cameraComponent(self, didFail: .cameraComponentError(reason: error)) + } + + @objc private func pinch(_ pinch: UIPinchGestureRecognizer) { + let device = videoDeviceInput.device + + // Return zoom value between the minimum and maximum zoom values + func minMaxZoom(_ factor: CGFloat) -> CGFloat { + min(min(max(factor, options.minimumZoomScale), + options.maximumZoomScale), + device.activeFormat.videoMaxZoomFactor) + } + + func update(scale factor: CGFloat) { + do { + try device.lockForConfiguration() + device.videoZoomFactor = factor + device.unlockForConfiguration() + } catch { + notifyDelegateForError(.failedToLockDevice) + } + } + + let newScaleFactor = minMaxZoom(pinch.scale * lastZoomFactor) + switch pinch.state { + case .began: + break + case .changed: + update(scale: newScaleFactor) + case .ended: + lastZoomFactor = minMaxZoom(newScaleFactor) + update(scale: lastZoomFactor) + default: + break + } + } +} + +// MARK: - Notifications +extension CameraComponent { + func removeObserver() { + keyValueObservations.forEach { $0.invalidate() } + keyValueObservations.removeAll() + } + + private func addObserver() { + let systemPressureStateObservation = observe(\.self.videoDeviceInput.device.systemPressureState, options: .new) { _, change in + guard let systemPressureState = change.newValue else { return } + self.setRecommendedFrameRateRangeForPressureState(systemPressureState: systemPressureState) + } + keyValueObservations.append(systemPressureStateObservation) + } + + private func setRecommendedFrameRateRangeForPressureState(systemPressureState: AVCaptureDevice.SystemPressureState) { + let pressureLevel = systemPressureState.level + if pressureLevel == .serious || pressureLevel == .critical { + if !movieFileOutput.isRecording { + do { + try videoDeviceInput.device.lockForConfiguration() + videoDeviceInput.device.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 20) + videoDeviceInput.device.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 15) + videoDeviceInput.device.unlockForConfiguration() + } catch { + notifyDelegateForError(.failedToLockDevice) + } + } + } else if pressureLevel == .shutdown { + notifyDelegateForError(.pressureLevelShutdown) + } + } +} + +// MARK: - AVCapturePhotoCaptureDelegate +extension CameraComponent: AVCapturePhotoCaptureDelegate { + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + guard error == nil else { + notifyDelegateForError(.failedToOutputPhoto(message: error?.localizedDescription)) + return + } + photoData = photo.fileDataRepresentation() + } + + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, + error: Error?) { + guard error == nil, let photoData else { + notifyDelegateForError(.failedToOutputPhoto(message: error?.localizedDescription)) + return + } + delegate?.cameraComponent(self, didCapturePhoto: photoData) + } +} + +// MARK: - AVCaptureFileOutputRecordingDelegate +extension CameraComponent: AVCaptureFileOutputRecordingDelegate { + func fileOutput(_ output: AVCaptureFileOutput, + didStartRecordingTo fileURL: URL, + from connections: [AVCaptureConnection]) { + delegate?.cameraComponent(self, didStartRecordingVideo: fileURL) + } + + func fileOutput(_ output: AVCaptureFileOutput, + didFinishRecordingTo outputFileURL: URL, + from connections: [AVCaptureConnection], + error: Error?) { + guard error == nil else { + cleanup(outputFileURL) + notifyDelegateForError(.failedToOutputMovie(message: error?.localizedDescription)) + return + } + delegate?.cameraComponent(self, didRecordVideo: outputFileURL) + } + + private func cleanup(_ url: URL) { + let path = url.path + if FileManager.default.fileExists(atPath: path) { + do { + try FileManager.default.removeItem(atPath: path) + } catch { + notifyDelegateForError(.failedToRemoveFileManagerItem) + } + } + } +} diff --git a/CameraKage/Tests/CameraKageTests/CameraKageTests.swift b/CameraKage/Tests/CameraKageTests/CameraKageTests.swift new file mode 100644 index 0000000..09c78fa --- /dev/null +++ b/CameraKage/Tests/CameraKageTests/CameraKageTests.swift @@ -0,0 +1,7 @@ +import XCTest +@testable import CameraKage + +final class CameraKageTests: XCTestCase { + func testExample() throws { + } +}