From ec1cfc534f2a640635eeb463c64af94cb2a33622 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 8 Jun 2023 10:15:29 +0300 Subject: [PATCH] Composition refactors --- .../AudioCaptureDevice.swift | 37 ++ .../CaptureSession.swift | 21 ++ .../AVFoundationsInternals/MovieOutput.swift | 90 +++++ .../AVFoundationsInternals/PhotoOutput.swift | 75 ++++ .../AVFoundationsInternals/PreviewLayer.swift | 27 ++ .../VideoCaptureDevice.swift | 111 ++++++ Sources/CameraKage/CameraKage.swift | 67 ++-- .../Internal/CameraDevice+AVFoundation.swift | 63 ++++ .../Internal/MediaType+AVFoundation.swift | 17 + .../Extensions/URL/URL+TemporaryURL.swift | 2 +- .../CameraKage/General/Camera/Camera.swift | 322 +++++++----------- .../General/Camera/CameraComponent.swift | 128 +------ .../General/Camera/CameraComposer.swift | 26 +- .../Camera/CameraComposerProtocol.swift | 25 ++ .../Internal/CameraComponentDelegate.swift | 22 -- .../Delegates/Internal/CameraDelegate.swift | 16 + .../Permissions/PermissionsManager.swift | 14 +- .../PermissionsManagerProtocol.swift | 6 +- .../General/Session/SessionComposer.swift | 14 +- .../Settings/CameraComponentOptions.swift | 24 +- .../General/Settings/CameraDevice.swift | 45 +++ .../General/Settings/MediaType.swift | 6 +- .../General/Settings/OutputType.swift | 13 + Tests/CameraKageTests/CameraKageTests.swift | 157 +++++---- .../PermissionManagerMock.swift | 35 +- .../Utils/XCTestCase+TrackMemoryLeak.swift | 16 + 26 files changed, 882 insertions(+), 497 deletions(-) create mode 100644 Sources/CameraKage/AVFoundationsInternals/AudioCaptureDevice.swift create mode 100644 Sources/CameraKage/AVFoundationsInternals/CaptureSession.swift create mode 100644 Sources/CameraKage/AVFoundationsInternals/MovieOutput.swift create mode 100644 Sources/CameraKage/AVFoundationsInternals/PhotoOutput.swift create mode 100644 Sources/CameraKage/AVFoundationsInternals/PreviewLayer.swift create mode 100644 Sources/CameraKage/AVFoundationsInternals/VideoCaptureDevice.swift create mode 100644 Sources/CameraKage/Extensions/Internal/CameraDevice+AVFoundation.swift create mode 100644 Sources/CameraKage/Extensions/Internal/MediaType+AVFoundation.swift create mode 100644 Sources/CameraKage/General/Camera/CameraComposerProtocol.swift delete mode 100644 Sources/CameraKage/General/Delegates/Internal/CameraComponentDelegate.swift create mode 100644 Sources/CameraKage/General/Delegates/Internal/CameraDelegate.swift create mode 100644 Sources/CameraKage/General/Settings/CameraDevice.swift create mode 100644 Sources/CameraKage/General/Settings/OutputType.swift create mode 100644 Tests/CameraKageTests/Utils/XCTestCase+TrackMemoryLeak.swift diff --git a/Sources/CameraKage/AVFoundationsInternals/AudioCaptureDevice.swift b/Sources/CameraKage/AVFoundationsInternals/AudioCaptureDevice.swift new file mode 100644 index 0000000..22c4328 --- /dev/null +++ b/Sources/CameraKage/AVFoundationsInternals/AudioCaptureDevice.swift @@ -0,0 +1,37 @@ +// +// AudioCaptureDevice.swift +// +// +// Created by Lobont Andrei on 06.06.2023. +// + +import AVFoundation + +class AudioCaptureDevice { + private(set) var audioDevice: AVCaptureDevice! + private(set) var audioDeviceInput: AVCaptureDeviceInput! + private(set) var audioDevicePort: AVCaptureDeviceInput.Port! + + func configureAudioDevice(forSession session: CaptureSession, + andOptions options: CameraComponentParsedOptions, + isFlipped: Bool) -> Bool { + do { + let camera = isFlipped ? options.flipCameraDevice : options.cameraDevice + guard let audioDevice = AVCaptureDevice.default(for: .audio) else { return false } + self.audioDevice = audioDevice + + let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice) + guard session.canAddInput(audioDeviceInput) else { return false } + session.addInputWithNoConnections(audioDeviceInput) + self.audioDeviceInput = audioDeviceInput + + guard let audioPort = audioDeviceInput.ports(for: .audio, + sourceDeviceType: .builtInMicrophone, + sourceDevicePosition: camera.avDevicePosition).first else { return false } + self.audioDevicePort = audioPort + return true + } catch { + return false + } + } +} diff --git a/Sources/CameraKage/AVFoundationsInternals/CaptureSession.swift b/Sources/CameraKage/AVFoundationsInternals/CaptureSession.swift new file mode 100644 index 0000000..4a43ef5 --- /dev/null +++ b/Sources/CameraKage/AVFoundationsInternals/CaptureSession.swift @@ -0,0 +1,21 @@ +// +// CaptureSession.swift +// +// +// Created by Lobont Andrei on 05.06.2023. +// + +import AVFoundation + +class CaptureSession: AVCaptureMultiCamSession { + func cleanupSession() { + defer { + commitConfiguration() + } + beginConfiguration() + + outputs.forEach { removeOutput($0) } + inputs.forEach { removeInput($0) } + connections.forEach { removeConnection($0) } + } +} diff --git a/Sources/CameraKage/AVFoundationsInternals/MovieOutput.swift b/Sources/CameraKage/AVFoundationsInternals/MovieOutput.swift new file mode 100644 index 0000000..1c4d1f8 --- /dev/null +++ b/Sources/CameraKage/AVFoundationsInternals/MovieOutput.swift @@ -0,0 +1,90 @@ +// +// MovieOutput.swift +// +// +// Created by Lobont Andrei on 05.06.2023. +// + +import AVFoundation + +class MovieOutput: AVCaptureMovieFileOutput { + private(set) var videoPortConnection: AVCaptureConnection? + private(set) var audioPortConnection: AVCaptureConnection? + + var onMovieCaptureSuccess: ((URL) -> Void)? + var onMovieCaptureStart: ((URL) -> Void)? + var onMovieCaptureError: ((CameraError) -> Void)? + + func startMovieRecording() { + guard !isRecording else { return } + startRecording(to: .makeTempUrl(for: .video), recordingDelegate: self) + } + + func stopMovieRecording() { + stopRecording() + } + + func configureMovieFileOutput(forSession session: CaptureSession, + andOptions options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice, + audioDevice: AudioCaptureDevice, + isFlipped: Bool) -> Bool { + let camera = isFlipped ? options.flipCameraDevice : options.cameraDevice + guard session.canAddOutput(self) else { return false } + session.addOutputWithNoConnections(self) + maxRecordedDuration = options.maxVideoDuration + + let videoConnection = AVCaptureConnection(inputPorts: [videoDevice.videoDevicePort], output: self) + guard session.canAddConnection(videoConnection) else { return false } + session.addConnection(videoConnection) + videoConnection.isVideoMirrored = camera.avDevicePosition == .front + videoConnection.videoOrientation = options.cameraOrientation + if videoConnection.isVideoStabilizationSupported { + videoConnection.preferredVideoStabilizationMode = options.videoStabilizationMode + } + self.videoPortConnection = videoConnection + + let audioConnection = AVCaptureConnection(inputPorts: [audioDevice.audioDevicePort], output: self) + guard session.canAddConnection(audioConnection) else { return false } + session.addConnection(audioConnection) + if availableVideoCodecTypes.contains(.hevc) { + setOutputSettings([AVVideoCodecKey: AVVideoCodecType.hevc], + for: videoConnection) + } + self.audioPortConnection = audioConnection + + return true + } +} + +// MARK: - AVCaptureFileOutputRecordingDelegate +extension MovieOutput: AVCaptureFileOutputRecordingDelegate { + func fileOutput(_ output: AVCaptureFileOutput, + didStartRecordingTo fileURL: URL, + from connections: [AVCaptureConnection]) { + onMovieCaptureStart?(fileURL) + } + + func fileOutput(_ output: AVCaptureFileOutput, + didFinishRecordingTo outputFileURL: URL, + from connections: [AVCaptureConnection], + error: Error?) { + guard error == nil else { + cleanup(outputFileURL) + onMovieCaptureError?(.cameraComponentError(reason: .failedToOutputMovie(message: error?.localizedDescription))) + return + } + onMovieCaptureSuccess?(outputFileURL) + } + + private func cleanup(_ url: URL) { + let path = url.path + if FileManager.default.fileExists(atPath: path) { + do { + try FileManager.default.removeItem(atPath: path) + } catch { + onMovieCaptureError?(.cameraComponentError(reason: .failedToRemoveFileManagerItem)) + } + } + } +} diff --git a/Sources/CameraKage/AVFoundationsInternals/PhotoOutput.swift b/Sources/CameraKage/AVFoundationsInternals/PhotoOutput.swift new file mode 100644 index 0000000..a3927af --- /dev/null +++ b/Sources/CameraKage/AVFoundationsInternals/PhotoOutput.swift @@ -0,0 +1,75 @@ +// +// PhotoOutput.swift +// +// +// Created by Lobont Andrei on 05.06.2023. +// + +import AVFoundation + +class PhotoOutput: AVCapturePhotoOutput { + private var photoData: Data? + + private(set) var videoPortConnection: AVCaptureConnection? + + var onPhotoCaptureSuccess: ((Data) -> Void)? + var onPhotoCaptureError: ((CameraError) -> Void)? + + func capturePhoto(_ flashMode: FlashMode, + redEyeCorrection: Bool) { + var photoSettings = AVCapturePhotoSettings() + photoSettings.flashMode = flashMode.avFlashOption + photoSettings.isAutoRedEyeReductionEnabled = redEyeCorrection + + if availablePhotoCodecTypes.contains(.hevc) { + photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc]) + } + if let previewPhotoPixelFormatType = photoSettings.availablePreviewPhotoPixelFormatTypes.first { + photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: previewPhotoPixelFormatType] + } + + capturePhoto(with: photoSettings, delegate: self) + } + + func configurePhotoOutput(forSession session: CaptureSession, + andOptions options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice, + isFlipped: Bool) -> Bool { + let camera = isFlipped ? options.flipCameraDevice : options.cameraDevice + guard session.canAddOutput(self) else { return false } + session.addOutputWithNoConnections(self) + maxPhotoQualityPrioritization = options.photoQualityPrioritizationMode + + let photoConnection = AVCaptureConnection(inputPorts: [videoDevice.videoDevicePort], output: self) + guard session.canAddConnection(photoConnection) else { return false } + session.addConnection(photoConnection) + photoConnection.videoOrientation = options.cameraOrientation + photoConnection.isVideoMirrored = camera.avDevicePosition == .front + self.videoPortConnection = photoConnection + + return true + } +} + +// MARK: - AVCapturePhotoCaptureDelegate +extension PhotoOutput: AVCapturePhotoCaptureDelegate { + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + guard error == nil else { + onPhotoCaptureError?(.cameraComponentError(reason: .failedToOutputPhoto(message: error?.localizedDescription))) + return + } + photoData = photo.fileDataRepresentation() + } + + func photoOutput(_ output: AVCapturePhotoOutput, + didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, + error: Error?) { + guard error == nil, let photoData else { + onPhotoCaptureError?(.cameraComponentError(reason: .failedToOutputPhoto(message: error?.localizedDescription))) + return + } + onPhotoCaptureSuccess?(photoData) + } +} diff --git a/Sources/CameraKage/AVFoundationsInternals/PreviewLayer.swift b/Sources/CameraKage/AVFoundationsInternals/PreviewLayer.swift new file mode 100644 index 0000000..f0745a5 --- /dev/null +++ b/Sources/CameraKage/AVFoundationsInternals/PreviewLayer.swift @@ -0,0 +1,27 @@ +// +// PreviewLayer.swift +// +// +// Created by Lobont Andrei on 06.06.2023. +// + +import AVFoundation + +class PreviewLayer: AVCaptureVideoPreviewLayer { + private(set) var previewLayerConnection: AVCaptureConnection! + + func configurePreviewLayer(forSession session: CaptureSession, + andOptions options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice) -> Bool { + setSessionWithNoConnection(session) + videoGravity = options.videoGravity + + let previewLayerConnection = AVCaptureConnection(inputPort: videoDevice.videoDevicePort, videoPreviewLayer: self) + previewLayerConnection.videoOrientation = options.cameraOrientation + guard session.canAddConnection(previewLayerConnection) else { return false } + session.addConnection(previewLayerConnection) + self.previewLayerConnection = previewLayerConnection + + return true + } +} diff --git a/Sources/CameraKage/AVFoundationsInternals/VideoCaptureDevice.swift b/Sources/CameraKage/AVFoundationsInternals/VideoCaptureDevice.swift new file mode 100644 index 0000000..1b7ad60 --- /dev/null +++ b/Sources/CameraKage/AVFoundationsInternals/VideoCaptureDevice.swift @@ -0,0 +1,111 @@ +// +// VideoCaptureDevice.swift +// +// +// Created by Lobont Andrei on 06.06.2023. +// + +import AVFoundation + +class VideoCaptureDevice: NSObject { + @objc private(set) dynamic var videoDevice: AVCaptureDevice! + @objc private(set) dynamic var videoDeviceInput: AVCaptureDeviceInput! + private(set) var videoDevicePort: AVCaptureDeviceInput.Port! + + private var keyValueObservations = [NSKeyValueObservation]() + + var onVideoDeviceError: ((CameraError) -> Void)? + + func focus(with focusMode: FocusMode, + exposureMode: ExposureMode, + at point: CGPoint, + monitorSubjectAreaChange: Bool) throws { + do { + try videoDevice.lockForConfiguration() + if videoDevice.isFocusPointOfInterestSupported && + videoDevice.isFocusModeSupported(focusMode.avFocusOption) { + videoDevice.focusPointOfInterest = point + videoDevice.focusMode = focusMode.avFocusOption + } + if videoDevice.isExposurePointOfInterestSupported && + videoDevice.isExposureModeSupported(exposureMode.avExposureOption) { + videoDevice.exposurePointOfInterest = point + videoDevice.exposureMode = exposureMode.avExposureOption + } + videoDevice.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange + videoDevice.unlockForConfiguration() + } catch { + throw CameraError.cameraComponentError(reason: .failedToLockDevice) + } + } + + func zoom(atScale: CGFloat) throws { + do { + try videoDevice.lockForConfiguration() + videoDevice.videoZoomFactor = atScale + videoDevice.unlockForConfiguration() + } catch { + throw CameraError.cameraComponentError(reason: .failedToLockDevice) + } + } + + func minMaxZoom(_ factor: CGFloat, + with options: CameraComponentParsedOptions) -> CGFloat { + let maxFactor = max(factor, options.minimumZoomScale) + return min(min(maxFactor, options.maximumZoomScale), videoDevice.activeFormat.videoMaxZoomFactor) + } + + func configureVideoDevice(forSession session: CaptureSession, + andOptions options: CameraComponentParsedOptions, + isFlipped: Bool) -> Bool { + do { + let camera = isFlipped ? options.flipCameraDevice : options.cameraDevice + + guard let videoDevice = AVCaptureDevice.default(camera.avDeviceType, + for: .video, + position: camera.avDevicePosition) else { return false } + self.videoDevice = videoDevice + let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) + guard session.canAddInput(videoDeviceInput) else { return false } + session.addInputWithNoConnections(videoDeviceInput) + self.videoDeviceInput = videoDeviceInput + + guard let videoPort = videoDeviceInput.ports(for: .video, + sourceDeviceType: camera.avDeviceType, + sourceDevicePosition: camera.avDevicePosition).first else { return false } + self.videoDevicePort = videoPort + return true + } catch { + return false + } + } + + func removeObserver() { + keyValueObservations.forEach { $0.invalidate() } + keyValueObservations.removeAll() + } + + func addObserver() { + let systemPressureStateObservation = observe(\.videoDevice.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 { + do { + try videoDevice.lockForConfiguration() + videoDevice.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 20) + videoDevice.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 15) + videoDevice.unlockForConfiguration() + } catch { + onVideoDeviceError?(.cameraComponentError(reason: .failedToLockDevice)) + } + } else if pressureLevel == .shutdown { + onVideoDeviceError?(.cameraComponentError(reason: .pressureLevelShutdown)) + } + } +} diff --git a/Sources/CameraKage/CameraKage.swift b/Sources/CameraKage/CameraKage.swift index b09abc7..02340b7 100644 --- a/Sources/CameraKage/CameraKage.swift +++ b/Sources/CameraKage/CameraKage.swift @@ -1,11 +1,10 @@ import UIKit -import AVFoundation /// The main interface to use the `CameraKage` camera features. public class CameraKage: UIView { - private let permissionManager: PermissionsManagerProtocol = PermissionsManager() - private let delegatesManager: DelegatesManagerProtocol = DelegatesManager() - private var cameraComposer: CameraComposer = CameraComposer() + private var permissionManager: PermissionsManagerProtocol = PermissionsManager() + private var delegatesManager: DelegatesManagerProtocol = DelegatesManager() + private var cameraComposer: CameraComposerProtocol! /// Determines if the CaptureSession of `CameraKage` is running. public var isSessionRunning: Bool { cameraComposer.isSessionRunning } @@ -13,6 +12,9 @@ public class CameraKage: UIView { /// Determines if `CameraKage` has a video recording in progress. public var isRecording: Bool { cameraComposer.isRecording } + /// Available cameras for the client's phone. + public var availableCameraDevices: [CameraDevice] { CameraDevice.availableDevices } + public override init(frame: CGRect) { super.init(frame: frame) setupComposer() @@ -107,26 +109,6 @@ public class CameraKage: UIView { 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. @@ -198,9 +180,13 @@ public class CameraKage: UIView { } private func setupComposer() { - cameraComposer.delegate = self - addSubview(cameraComposer) - cameraComposer.layoutToFill(inView: self) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + cameraComposer = CameraComposer() + cameraComposer.delegate = self + addSubview(cameraComposer) + cameraComposer.layoutToFill(inView: self) + } } } @@ -246,3 +232,30 @@ extension CameraKage: CameraComposerDelegate { delegatesManager.invokeDelegates { $0.cameraDeviceDidChangeSubjectArea(self) } } } + +// MARK: - Internal tests inits +extension CameraKage { + internal convenience init(permissionManager: PermissionsManagerProtocol, + delegatesManager: DelegatesManagerProtocol, + cameraComposer: CameraComposerProtocol) { + self.init(frame: .zero) + self.permissionManager = permissionManager + self.delegatesManager = delegatesManager + self.cameraComposer = cameraComposer + } + + internal convenience init(delegatesManager: DelegatesManagerProtocol) { + self.init(frame: .zero) + self.delegatesManager = delegatesManager + } + + internal convenience init(permissionManager: PermissionsManagerProtocol) { + self.init(frame: .zero) + self.permissionManager = permissionManager + } + + internal convenience init(cameraComposer: CameraComposerProtocol) { + self.init(frame: .zero) + self.cameraComposer = cameraComposer + } +} diff --git a/Sources/CameraKage/Extensions/Internal/CameraDevice+AVFoundation.swift b/Sources/CameraKage/Extensions/Internal/CameraDevice+AVFoundation.swift new file mode 100644 index 0000000..ee7c6b7 --- /dev/null +++ b/Sources/CameraKage/Extensions/Internal/CameraDevice+AVFoundation.swift @@ -0,0 +1,63 @@ +// +// CameraDevice+AVFoundation.swift +// +// +// Created by Lobont Andrei on 05.06.2023. +// + +import AVFoundation + +extension CameraDevice { + var avDevicePosition: AVCaptureDevice.Position { self == .frontCamera ? .front : .back } + + var avDeviceType: AVCaptureDevice.DeviceType { + switch self { + case .frontCamera: return .builtInWideAngleCamera + case .backWideCamera: return .builtInWideAngleCamera + case .backTelephotoCamera: return .builtInTelephotoCamera + case .backUltraWideCamera: return .builtInUltraWideCamera + case .backDualCamera: return .builtInDualCamera + case .backWideDualCamera: return .builtInDualWideCamera + case .backTripleCamera: return .builtInTripleCamera + } + } + + static var availableDevices: [CameraDevice] { + var devices: [CameraDevice] = [] + let avBackDevices = AVCaptureDevice.DiscoverySession(deviceTypes: [ + AVCaptureDevice.DeviceType.builtInWideAngleCamera, + AVCaptureDevice.DeviceType.builtInUltraWideCamera, + AVCaptureDevice.DeviceType.builtInTelephotoCamera, + AVCaptureDevice.DeviceType.builtInDualCamera, + AVCaptureDevice.DeviceType.builtInDualWideCamera, + AVCaptureDevice.DeviceType.builtInTripleCamera, + ], + mediaType: .video, + position: .back) + avBackDevices.devices.forEach { device in + switch device.deviceType { + case .builtInWideAngleCamera: devices.append(.backWideCamera) + case .builtInUltraWideCamera: devices.append(.backUltraWideCamera) + case .builtInTelephotoCamera: devices.append(.backTelephotoCamera) + case .builtInDualCamera: devices.append(.backDualCamera) + case .builtInDualWideCamera: devices.append(.backWideDualCamera) + case .builtInTripleCamera: devices.append(.backTripleCamera) + default: break // Not using other device types + } + } + + let avFrontDevices = AVCaptureDevice.DiscoverySession(deviceTypes: [ + AVCaptureDevice.DeviceType.builtInWideAngleCamera + ], + mediaType: .video, + position: .front) + avFrontDevices.devices.forEach { device in + switch device.deviceType { + case .builtInWideAngleCamera: devices.append(.frontCamera) + default: break // Not using other device types + } + } + + return devices + } +} diff --git a/Sources/CameraKage/Extensions/Internal/MediaType+AVFoundation.swift b/Sources/CameraKage/Extensions/Internal/MediaType+AVFoundation.swift new file mode 100644 index 0000000..95c408b --- /dev/null +++ b/Sources/CameraKage/Extensions/Internal/MediaType+AVFoundation.swift @@ -0,0 +1,17 @@ +// +// MediaType+AVFoundation.swift +// +// +// Created by Lobont Andrei on 07.06.2023. +// + +import AVFoundation + +extension MediaType { + var avMediaType: AVMediaType { + switch self { + case .audio: return .audio + case .video: return .video + } + } +} diff --git a/Sources/CameraKage/Extensions/URL/URL+TemporaryURL.swift b/Sources/CameraKage/Extensions/URL/URL+TemporaryURL.swift index f9c6070..b063a94 100644 --- a/Sources/CameraKage/Extensions/URL/URL+TemporaryURL.swift +++ b/Sources/CameraKage/Extensions/URL/URL+TemporaryURL.swift @@ -8,7 +8,7 @@ import Foundation extension URL { - static func makeTempUrl(for type: MediaType) -> URL { + static func makeTempUrl(for type: OutputType) -> URL { let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) switch type { case .photo: return url.appendingPathExtension("jpg") diff --git a/Sources/CameraKage/General/Camera/Camera.swift b/Sources/CameraKage/General/Camera/Camera.swift index f2ac8d9..8492192 100644 --- a/Sources/CameraKage/General/Camera/Camera.swift +++ b/Sources/CameraKage/General/Camera/Camera.swift @@ -5,133 +5,122 @@ // Created by Lobont Andrei on 30.05.2023. // -import AVFoundation +import Foundation +import QuartzCore.CALayer -class Camera: NSObject { - let session: AVCaptureMultiCamSession +class Camera { + let session: CaptureSession let options: CameraComponentParsedOptions - @objc dynamic var videoDevice: AVCaptureDevice! - @objc dynamic var videoDeviceInput: AVCaptureDeviceInput! - var videoDevicePort: AVCaptureDeviceInput.Port! - - var audioDevice: AVCaptureDevice! - var audioDeviceInput: AVCaptureDeviceInput! - var audioDevicePort: AVCaptureDeviceInput.Port! - - var photoOutput: AVCapturePhotoOutput! - var photoVideoOutputConnection: AVCaptureConnection! - - var movieOutput: AVCaptureMovieFileOutput! - var movieVideoOutputConnection: AVCaptureConnection! - var movieAudioOutputConnection: AVCaptureConnection! - - var previewLayer: AVCaptureVideoPreviewLayer! - var previewLayerConnection: AVCaptureConnection! + private let videoDevice: VideoCaptureDevice + private let audioDevice: AudioCaptureDevice + private let photoOutput: PhotoOutput + private let movieOutput: MovieOutput + private let previewLayer: PreviewLayer + private var lastZoomFactor: CGFloat = 1.0 + private var isFlipped = false var allowsPinchZoom: Bool { options.pinchToZoomEnabled } - var onCameraSystemPressureError: ((CameraError) -> Void)? var isRecording: Bool { movieOutput.isRecording } - private var keyValueObservations = [NSKeyValueObservation]() + weak var delegate: CameraDelegate? - init?(session: AVCaptureMultiCamSession, - options: CameraComponentParsedOptions) throws { + init?(session: CaptureSession, + options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice = VideoCaptureDevice(), + audioDevice: AudioCaptureDevice = AudioCaptureDevice(), + photoOutput: PhotoOutput = PhotoOutput(), + movieOutput: MovieOutput = MovieOutput(), + previewLayer: PreviewLayer = PreviewLayer()) { self.session = session self.options = options - super.init() + self.videoDevice = videoDevice + self.audioDevice = audioDevice + self.photoOutput = photoOutput + self.movieOutput = movieOutput + self.previewLayer = previewLayer + do { try configureSession() } catch let error as CameraError { - throw error + delegate?.camera(self, didFail: error) + return nil } catch { - throw CameraError.cameraComponentError(reason: .failedToComposeCamera) + delegate?.camera(self, didFail: .cameraComponentError(reason: .failedToComposeCamera)) + return nil } } deinit { - removeObserver() + videoDevice.removeObserver() } - func capturePhoto(_ flashMode: FlashMode, - redEyeCorrection: Bool, - delegate: AVCapturePhotoCaptureDelegate) { - var photoSettings = AVCapturePhotoSettings() - photoSettings.flashMode = flashMode.avFlashOption - 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: delegate) + func embedPreviewLayer(in layer: CALayer) { + layer.addSublayer(previewLayer) + } + + func setPreviewLayerFrame(frame: CGRect) { + previewLayer.bounds = frame + previewLayer.position = CGPoint(x: frame.width / 2, y: frame.height / 2) + } + + func capturePhoto(_ flashMode: FlashMode, redEyeCorrection: Bool) { + photoOutput.capturePhoto(flashMode, redEyeCorrection: redEyeCorrection) } - func startMovieRecording(delegate: AVCaptureFileOutputRecordingDelegate) { - guard !movieOutput.isRecording else { return } - movieOutput.startRecording(to: .makeTempUrl(for: .video), recordingDelegate: delegate) + func startMovieRecording() { + movieOutput.startMovieRecording() } func stopMovieRecording() { - movieOutput.stopRecording() + movieOutput.stopMovieRecording() } func focus(with focusMode: FocusMode, exposureMode: ExposureMode, at devicePoint: CGPoint, - monitorSubjectAreaChange: Bool) throws { + monitorSubjectAreaChange: Bool) { let point = previewLayer.captureDevicePointConverted(fromLayerPoint: devicePoint) do { - try videoDevice.lockForConfiguration() - if videoDevice.isFocusPointOfInterestSupported && - videoDevice.isFocusModeSupported(focusMode.avFocusOption) { - videoDevice.focusPointOfInterest = point - videoDevice.focusMode = focusMode.avFocusOption - } - if videoDevice.isExposurePointOfInterestSupported && - videoDevice.isExposureModeSupported(exposureMode.avExposureOption) { - videoDevice.exposurePointOfInterest = point - videoDevice.exposureMode = exposureMode.avExposureOption - } - videoDevice.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange - videoDevice.unlockForConfiguration() + try videoDevice.focus(with: focusMode, + exposureMode: exposureMode, + at: point, + monitorSubjectAreaChange: monitorSubjectAreaChange) + } catch let error as CameraError { + delegate?.camera(self, didFail: error) } catch { - throw CameraError.cameraComponentError(reason: .failedToLockDevice) + delegate?.camera(self, didFail: .cameraComponentError(reason: .failedToLockDevice)) } } - func flipCamera() throws { + func flipCamera() { do { - options.devicePosition = options.devicePosition == .back ? .front : .back - removeObserver() - removeDevices() + isFlipped.toggle() + DispatchQueue.main.async { + self.previewLayer.removeFromSuperlayer() + } + videoDevice.removeObserver() + session.cleanupSession() try configureSession() - addObserver() + videoDevice.addObserver() } catch let error as CameraError { - throw error + delegate?.camera(self, didFail: error) } catch { - throw CameraError.cameraComponentError(reason: .failedToComposeCamera) + delegate?.camera(self, didFail: .cameraComponentError(reason: .failedToLockDevice)) } } - func zoom(atScale: CGFloat) throws { + func zoom(atScale: CGFloat) { + lastZoomFactor = videoDevice.minMaxZoom(atScale * lastZoomFactor, with: options) do { - try videoDevice.lockForConfiguration() - videoDevice.videoZoomFactor = atScale - videoDevice.unlockForConfiguration() + try videoDevice.zoom(atScale: lastZoomFactor) + } catch let error as CameraError { + delegate?.camera(self, didFail: error) } catch { - throw CameraError.cameraComponentError(reason: .failedToLockDevice) + delegate?.camera(self, didFail: .cameraComponentError(reason: .failedToLockDevice)) } } - func minMaxZoom(_ factor: CGFloat) -> CGFloat { - let maxFactor = max(factor, options.minimumZoomScale) - return min(min(maxFactor, options.maximumZoomScale), videoDevice.activeFormat.videoMaxZoomFactor) - } - private func configureSession() throws { defer { session.commitConfiguration() @@ -144,7 +133,7 @@ class Camera: NSObject { guard configureAudioDevice() else { throw CameraError.cameraComponentError(reason: .failedToConfigureAudioDevice) } - guard configureMovieFileOutput() else { + guard configureMovieOutput() else { throw CameraError.cameraComponentError(reason: .failedToAddMovieOutput) } guard configurePhotoOutput() else { @@ -156,149 +145,68 @@ class Camera: NSObject { } private func configureVideoDevice() -> Bool { - do { - guard let videoDevice = AVCaptureDevice.default(options.deviceType, - for: .video, - position: options.devicePosition) else { return false } - self.videoDevice = videoDevice - let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice) - guard session.canAddInput(videoDeviceInput) else { return false } - session.addInputWithNoConnections(videoDeviceInput) - self.videoDeviceInput = videoDeviceInput - - guard let videoPort = videoDeviceInput.ports(for: .video, - sourceDeviceType: options.deviceType, - sourceDevicePosition: options.devicePosition).first else { return false } - self.videoDevicePort = videoPort - return true - } catch { - return false + let configurationResult = videoDevice.configureVideoDevice(forSession: session, + andOptions: options, + isFlipped: isFlipped) + videoDevice.addObserver() + videoDevice.onVideoDeviceError = { [weak self] error in + guard let self else { return } + delegate?.camera(self, didFail: error) } + + return configurationResult } private func configureAudioDevice() -> Bool { - do { - guard let audioDevice = AVCaptureDevice.default(for: .audio) else { return false } - self.audioDevice = audioDevice - - let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice) - guard session.canAddInput(audioDeviceInput) else { return false } - session.addInputWithNoConnections(audioDeviceInput) - self.audioDeviceInput = audioDeviceInput - - guard let audioPort = audioDeviceInput.ports(for: .audio, - sourceDeviceType: .builtInMicrophone, - sourceDevicePosition: options.devicePosition).first else { return false } - self.audioDevicePort = audioPort - return true - } catch { - return false - } + audioDevice.configureAudioDevice(forSession: session, + andOptions: options, + isFlipped: isFlipped) } - private func configurePhotoOutput() -> Bool { - let photoOutput = AVCapturePhotoOutput() - guard session.canAddOutput(photoOutput) else { return false } - session.addOutputWithNoConnections(photoOutput) - photoOutput.maxPhotoQualityPrioritization = options.photoQualityPrioritizationMode - self.photoOutput = photoOutput + private func configureMovieOutput() -> Bool { + let configurationResult = movieOutput.configureMovieFileOutput(forSession: session, + andOptions: options, + videoDevice: videoDevice, + audioDevice: audioDevice, + isFlipped: isFlipped) - let photoConnection = AVCaptureConnection(inputPorts: [videoDevicePort], output: photoOutput) - guard session.canAddConnection(photoConnection) else { return false } - session.addConnection(photoConnection) - photoConnection.videoOrientation = options.cameraOrientation - photoConnection.isVideoMirrored = options.devicePosition == .front - self.photoVideoOutputConnection = photoConnection - - return true - } - - private func configureMovieFileOutput() -> Bool { - let movieFileOutput = AVCaptureMovieFileOutput() - guard session.canAddOutput(movieFileOutput) else { return false } - session.addOutputWithNoConnections(movieFileOutput) - movieFileOutput.maxRecordedDuration = options.maxVideoDuration - self.movieOutput = movieFileOutput - - let videoConnection = AVCaptureConnection(inputPorts: [videoDevicePort], output: movieFileOutput) - guard session.canAddConnection(videoConnection) else { return false } - session.addConnection(videoConnection) - videoConnection.isVideoMirrored = options.devicePosition == .front - videoConnection.videoOrientation = options.cameraOrientation - if videoConnection.isVideoStabilizationSupported { - videoConnection.preferredVideoStabilizationMode = options.videoStabilizationMode + movieOutput.onMovieCaptureStart = { [weak self] url in + guard let self else { return } + delegate?.camera(self, didStartRecordingVideo: url) } - self.movieVideoOutputConnection = videoConnection - - let audioConnection = AVCaptureConnection(inputPorts: [audioDevicePort], output: movieFileOutput) - guard session.canAddConnection(audioConnection) else { return false } - session.addConnection(audioConnection) - let availableVideoCodecTypes = movieFileOutput.availableVideoCodecTypes - if availableVideoCodecTypes.contains(.hevc) { - movieFileOutput.setOutputSettings([AVVideoCodecKey: AVVideoCodecType.hevc], - for: videoConnection) + movieOutput.onMovieCaptureSuccess = { [weak self] url in + guard let self else { return } + delegate?.camera(self, didRecordVideo: url) + } + movieOutput.onMovieCaptureError = { [weak self] error in + guard let self else { return } + delegate?.camera(self, didFail: error) } - self.movieAudioOutputConnection = audioConnection - return true + return configurationResult } - private func configurePreviewLayer() -> Bool { - let previewLayer = AVCaptureVideoPreviewLayer(sessionWithNoConnection: session) - previewLayer.videoGravity = options.videoGravity - self.previewLayer = previewLayer - - let previewLayerConnection = AVCaptureConnection(inputPort: videoDevicePort, videoPreviewLayer: previewLayer) - previewLayerConnection.videoOrientation = options.cameraOrientation - guard session.canAddConnection(previewLayerConnection) else { return false } - session.addConnection(previewLayerConnection) - self.previewLayerConnection = previewLayerConnection + private func configurePhotoOutput() -> Bool { + let configurationResult = photoOutput.configurePhotoOutput(forSession: session, + andOptions: options, + videoDevice: videoDevice, + isFlipped: isFlipped) - return true - } - - private func removeDevices() { - defer { - session.commitConfiguration() + photoOutput.onPhotoCaptureSuccess = { [weak self] data in + guard let self else { return } + delegate?.camera(self, didCapturePhoto: data) } - session.beginConfiguration() - - session.outputs.forEach { session.removeOutput($0) } - session.inputs.forEach { session.removeInput($0) } - session.connections.forEach { session.removeConnection($0) } - } -} - -// MARK: - Notifications -extension Camera { - func removeObserver() { - keyValueObservations.forEach { $0.invalidate() } - keyValueObservations.removeAll() - } - - private func addObserver() { - let systemPressureStateObservation = observe(\.videoDevice.systemPressureState, options: .new) { _, change in - guard let systemPressureState = change.newValue else { return } - self.setRecommendedFrameRateRangeForPressureState(systemPressureState: systemPressureState) + photoOutput.onPhotoCaptureError = { [weak self] error in + guard let self else { return } + delegate?.camera(self, didFail: error) } - keyValueObservations.append(systemPressureStateObservation) + + return configurationResult } - private func setRecommendedFrameRateRangeForPressureState(systemPressureState: AVCaptureDevice.SystemPressureState) { - let pressureLevel = systemPressureState.level - if pressureLevel == .serious || pressureLevel == .critical { - if !movieOutput.isRecording { - do { - try videoDevice.lockForConfiguration() - videoDevice.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 20) - videoDevice.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 15) - videoDevice.unlockForConfiguration() - } catch { - onCameraSystemPressureError?(.cameraComponentError(reason: .failedToLockDevice)) - } - } - } else if pressureLevel == .shutdown { - onCameraSystemPressureError?(.cameraComponentError(reason: .pressureLevelShutdown)) - } + private func configurePreviewLayer() -> Bool { + previewLayer.configurePreviewLayer(forSession: session, + andOptions: options, + videoDevice: videoDevice) } } diff --git a/Sources/CameraKage/General/Camera/CameraComponent.swift b/Sources/CameraKage/General/Camera/CameraComponent.swift index 77c04ab..8c22bed 100644 --- a/Sources/CameraKage/General/Camera/CameraComponent.swift +++ b/Sources/CameraKage/General/Camera/CameraComponent.swift @@ -10,20 +10,15 @@ import AVFoundation class CameraComponent: UIView { private let camera: Camera - private var photoData: Data? - private var lastZoomFactor: CGFloat = 1.0 private lazy var pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(pinch(_:))) var isRecording: Bool { camera.isRecording } - - weak var delegate: CameraComponentDelegate? init(camera: Camera) { self.camera = camera super.init(frame: .zero) - layer.addSublayer(camera.previewLayer) + camera.embedPreviewLayer(in: layer) configurePinchGesture() - configureSystemPressureHandler() } required init?(coder: NSCoder) { @@ -32,17 +27,15 @@ class CameraComponent: UIView { override func layoutSubviews() { super.layoutSubviews() - camera.previewLayer.bounds = frame - camera.previewLayer.position = CGPoint(x: frame.width / 2, y: frame.height / 2) + camera.setPreviewLayerFrame(frame: frame) } - func capturePhoto(_ flashMode: FlashMode, - redEyeCorrection: Bool) { - camera.capturePhoto(flashMode, redEyeCorrection: redEyeCorrection, delegate: self) + func capturePhoto(_ flashMode: FlashMode, redEyeCorrection: Bool) { + camera.capturePhoto(flashMode, redEyeCorrection: redEyeCorrection) } func startMovieRecording() { - camera.startMovieRecording(delegate: self) + camera.startMovieRecording() } func stopMovieRecording() { @@ -50,16 +43,10 @@ class CameraComponent: UIView { } func flipCamera() { - do { - try camera.flipCamera() - DispatchQueue.main.async { - self.layer.addSublayer(self.camera.previewLayer) - self.layoutSubviews() - } - } catch let error as CameraError { - notifyDelegateForError(error) - } catch { - notifyDelegateForError(.cameraComponentError(reason: .failedToComposeCamera)) + camera.flipCamera() + DispatchQueue.main.async { + self.camera.embedPreviewLayer(in: self.layer) + self.layoutSubviews() } } @@ -67,16 +54,10 @@ class CameraComponent: UIView { exposureMode: ExposureMode, at devicePoint: CGPoint, monitorSubjectAreaChange: Bool) { - do { - try camera.focus(with: focusMode, - exposureMode: exposureMode, - at: devicePoint, - monitorSubjectAreaChange: monitorSubjectAreaChange) - } catch let error as CameraError { - notifyDelegateForError(error) - } catch { - notifyDelegateForError(.cameraComponentError(reason: .failedToLockDevice)) - } + camera.focus(with: focusMode, + exposureMode: exposureMode, + at: devicePoint, + monitorSubjectAreaChange: monitorSubjectAreaChange) } private func configurePinchGesture() { @@ -86,89 +67,8 @@ class CameraComponent: UIView { } } } - - private func configureSystemPressureHandler() { - camera.onCameraSystemPressureError = { [weak self] error in - guard let self else { return } - self.notifyDelegateForError(error) - } - } - - private func notifyDelegateForError(_ error: CameraError) { - delegate?.cameraComponent(self, didFail: error) - } @objc private func pinch(_ pinch: UIPinchGestureRecognizer) { - let newScaleFactor = camera.minMaxZoom(pinch.scale * lastZoomFactor) - do { - switch pinch.state { - case .changed: - try camera.zoom(atScale: newScaleFactor) - case .ended: - lastZoomFactor = camera.minMaxZoom(newScaleFactor) - try camera.zoom(atScale: lastZoomFactor) - default: - break - } - } catch let error as CameraError { - notifyDelegateForError(error) - } catch { - notifyDelegateForError(.cameraComponentError(reason: .failedToLockDevice)) - } - } -} - -// MARK: - AVCapturePhotoCaptureDelegate -extension CameraComponent: AVCapturePhotoCaptureDelegate { - func photoOutput(_ output: AVCapturePhotoOutput, - didFinishProcessingPhoto photo: AVCapturePhoto, - error: Error?) { - guard error == nil else { - notifyDelegateForError(.cameraComponentError(reason: .failedToOutputPhoto(message: error?.localizedDescription))) - return - } - photoData = photo.fileDataRepresentation() - } - - func photoOutput(_ output: AVCapturePhotoOutput, - didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, - error: Error?) { - guard error == nil, let photoData else { - notifyDelegateForError(.cameraComponentError(reason: .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(.cameraComponentError(reason: .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(.cameraComponentError(reason: .failedToRemoveFileManagerItem)) - } - } + camera.zoom(atScale: pinch.scale) } } diff --git a/Sources/CameraKage/General/Camera/CameraComposer.swift b/Sources/CameraKage/General/Camera/CameraComposer.swift index a1540f5..84f6e42 100644 --- a/Sources/CameraKage/General/Camera/CameraComposer.swift +++ b/Sources/CameraKage/General/Camera/CameraComposer.swift @@ -7,8 +7,8 @@ import UIKit -final class CameraComposer: UIView { - private var sessionComposer: SessionComposerProtocol = SessionComposer() +final class CameraComposer: UIView, CameraComposerProtocol { + private var sessionComposer: SessionComposerProtocol private let sessionQueue = DispatchQueue(label: "LA.cameraKage.sessionQueue") private var cameraComponent: CameraComponent! @@ -17,9 +17,10 @@ final class CameraComposer: UIView { weak var delegate: CameraComposerDelegate? - init() { + init(sessionComposer: SessionComposerProtocol = SessionComposer()) { + self.sessionComposer = sessionComposer super.init(frame: .zero) - sessionComposer.delegate = self + self.sessionComposer.delegate = self } required init?(coder: NSCoder) { @@ -42,8 +43,7 @@ final class CameraComposer: UIView { } } - func capturePhoto(_ flashOption: FlashMode, - redEyeCorrection: Bool) { + func capturePhoto(_ flashOption: FlashMode, redEyeCorrection: Bool) { sessionQueue.async { [weak self] in guard let self else { return } cameraComponent.capturePhoto(flashOption, redEyeCorrection: redEyeCorrection) @@ -95,7 +95,7 @@ final class CameraComposer: UIView { DispatchQueue.main.async { [weak self] in guard let self else { return } cameraComponent = CameraComponent(camera: camera) - cameraComponent.delegate = self + camera.delegate = self addSubview(cameraComponent) cameraComponent.layoutToFill(inView: self) } @@ -147,24 +147,24 @@ extension CameraComposer: SessionComposerDelegate { } // MARK: - CameraComponentDelegate -extension CameraComposer: CameraComponentDelegate { - func cameraComponent(_ cameraComponent: CameraComponent, didCapturePhoto photo: Data) { +extension CameraComposer: CameraDelegate { + func camera(_ camera: Camera, didCapturePhoto photo: Data) { delegate?.cameraComposer(self, didCapturePhoto: photo) } - func cameraComponent(_ cameraComponent: CameraComponent, didStartRecordingVideo atFileURL: URL) { + func camera(_ camera: Camera, didStartRecordingVideo atFileURL: URL) { delegate?.cameraComposer(self, didStartRecordingVideo: atFileURL) } - func cameraComponent(_ cameraComponent: CameraComponent, didRecordVideo videoURL: URL) { + func camera(_ camera: Camera, didRecordVideo videoURL: URL) { delegate?.cameraComposer(self, didRecordVideo: videoURL) } - func cameraComponent(_ cameraComponent: CameraComponent, didZoomAtScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) { + func camera(_ camera: Camera, didZoomAtScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) { delegate?.cameraComposer(self, didZoomAtScale: scale, outOfMaximumScale: maxScale) } - func cameraComponent(_ cameraComponent: CameraComponent, didFail withError: CameraError) { + func camera(_ camera: Camera, didFail withError: CameraError) { delegate?.cameraComposer(self, didReceiveError: withError) } } diff --git a/Sources/CameraKage/General/Camera/CameraComposerProtocol.swift b/Sources/CameraKage/General/Camera/CameraComposerProtocol.swift new file mode 100644 index 0000000..f5183c3 --- /dev/null +++ b/Sources/CameraKage/General/Camera/CameraComposerProtocol.swift @@ -0,0 +1,25 @@ +// +// CameraComposerProtocol.swift +// +// +// Created by Lobont Andrei on 08.06.2023. +// + +import UIKit + +protocol CameraComposerProtocol: UIView { + var isSessionRunning: Bool { get } + var isRecording: Bool { get } + var delegate: CameraComposerDelegate? { get set } + + func startCameraSession(with options: CameraComponentParsedOptions) + func stopCameraSession() + func capturePhoto(_ flashOption: FlashMode, redEyeCorrection: Bool) + func startVideoRecording() + func stopVideoRecording() + func flipCamera() + func adjustFocusAndExposure(with focusMode: FocusMode, + exposureMode: ExposureMode, + at devicePoint: CGPoint, + monitorSubjectAreaChange: Bool) +} diff --git a/Sources/CameraKage/General/Delegates/Internal/CameraComponentDelegate.swift b/Sources/CameraKage/General/Delegates/Internal/CameraComponentDelegate.swift deleted file mode 100644 index 5e37b4e..0000000 --- a/Sources/CameraKage/General/Delegates/Internal/CameraComponentDelegate.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// CameraComponentDelegate.swift -// -// -// Created by Lobont Andrei on 05.06.2023. -// - -import Foundation - -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, - didZoomAtScale scale: CGFloat, - outOfMaximumScale maxScale: CGFloat) - func cameraComponent(_ cameraComponent: CameraComponent, - didFail withError: CameraError) -} diff --git a/Sources/CameraKage/General/Delegates/Internal/CameraDelegate.swift b/Sources/CameraKage/General/Delegates/Internal/CameraDelegate.swift new file mode 100644 index 0000000..e408c69 --- /dev/null +++ b/Sources/CameraKage/General/Delegates/Internal/CameraDelegate.swift @@ -0,0 +1,16 @@ +// +// CameraDelegate.swift +// +// +// Created by Lobont Andrei on 05.06.2023. +// + +import Foundation + +protocol CameraDelegate: AnyObject { + func camera(_ camera: Camera, didCapturePhoto photo: Data) + func camera(_ camera: Camera, didStartRecordingVideo atFileURL: URL) + func camera(_ camera: Camera, didRecordVideo videoURL: URL) + func camera(_ camera: Camera, didZoomAtScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) + func camera(_ camera: Camera, didFail withError: CameraError) +} diff --git a/Sources/CameraKage/General/Permissions/PermissionsManager.swift b/Sources/CameraKage/General/Permissions/PermissionsManager.swift index f86d680..0117b11 100644 --- a/Sources/CameraKage/General/Permissions/PermissionsManager.swift +++ b/Sources/CameraKage/General/Permissions/PermissionsManager.swift @@ -8,29 +8,29 @@ import AVFoundation final class PermissionsManager: PermissionsManagerProtocol { - func getAuthorizationStatus(for media: AVMediaType) -> PermissionStatus { - switch AVCaptureDevice.authorizationStatus(for: media) { + func getAuthorizationStatus(for media: MediaType) -> PermissionStatus { + switch AVCaptureDevice.authorizationStatus(for: media.avMediaType) { case .notDetermined: return .notDetermined case .denied: return .denied case .authorized: return .authorized - default: return .denied + default: return .notDetermined } } - func requestAccess(for media: AVMediaType) async -> Bool { + func requestAccess(for media: MediaType) async -> Bool { let status = getAuthorizationStatus(for: media) var isAuthorized = status == .authorized if status == .notDetermined { - isAuthorized = await AVCaptureDevice.requestAccess(for: media) + isAuthorized = await AVCaptureDevice.requestAccess(for: media.avMediaType) } return isAuthorized } - func requestAccess(for media: AVMediaType, completion: @escaping((Bool) -> Void)) { + func requestAccess(for media: MediaType, completion: @escaping((Bool) -> Void)) { let status = getAuthorizationStatus(for: media) let isAuthorized = status == .authorized if status == .notDetermined { - AVCaptureDevice.requestAccess(for: media) { granted in + AVCaptureDevice.requestAccess(for: media.avMediaType) { granted in completion(granted) } } else { diff --git a/Sources/CameraKage/General/Permissions/PermissionsManagerProtocol.swift b/Sources/CameraKage/General/Permissions/PermissionsManagerProtocol.swift index 657ab24..6626ad2 100644 --- a/Sources/CameraKage/General/Permissions/PermissionsManagerProtocol.swift +++ b/Sources/CameraKage/General/Permissions/PermissionsManagerProtocol.swift @@ -8,7 +8,7 @@ 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)) + func getAuthorizationStatus(for media: MediaType) -> PermissionStatus + func requestAccess(for media: MediaType) async -> Bool + func requestAccess(for media: MediaType, completion: @escaping((Bool) -> Void)) } diff --git a/Sources/CameraKage/General/Session/SessionComposer.swift b/Sources/CameraKage/General/Session/SessionComposer.swift index 1b7bdd3..3c93a64 100644 --- a/Sources/CameraKage/General/Session/SessionComposer.swift +++ b/Sources/CameraKage/General/Session/SessionComposer.swift @@ -8,13 +8,13 @@ import AVFoundation final class SessionComposer: SessionComposerProtocol { - private let session: AVCaptureMultiCamSession + private let session: CaptureSession var isSessionRunning: Bool { session.isRunning } weak var delegate: SessionComposerDelegate? - init(session: AVCaptureMultiCamSession = AVCaptureMultiCamSession()) { + init(session: CaptureSession = CaptureSession()) { self.session = session } @@ -37,16 +37,10 @@ final class SessionComposer: SessionComposerProtocol { } func createCamera(_ options: CameraComponentParsedOptions) -> Result { - do { - guard let camera = try Camera(session: session, options: options) else { - return .failure(.cameraComponentError(reason: .failedToComposeCamera)) - } - return .success(camera) - } catch let error as CameraError { - return .failure(error) - } catch { + guard let camera = Camera(session: session, options: options) else { return .failure(.cameraComponentError(reason: .failedToComposeCamera)) } + return .success(camera) } } diff --git a/Sources/CameraKage/General/Settings/CameraComponentOptions.swift b/Sources/CameraKage/General/Settings/CameraComponentOptions.swift index e14e5f2..42f46a7 100644 --- a/Sources/CameraKage/General/Settings/CameraComponentOptions.swift +++ b/Sources/CameraKage/General/Settings/CameraComponentOptions.swift @@ -23,13 +23,13 @@ public enum CameraComponentOptionItem { /// Default is `.portrait`. case cameraOrientation(AVCaptureVideoOrientation) - /// The type of camera to be used on `CameraComponent`. - /// Default is `.builtInWideAngleCamera`. - case deviceType(AVCaptureDevice.DeviceType) + /// The type of camera to be used. + /// Default is `.backWideCamera`. + case cameraDevice(CameraDevice) - /// The position of the device. - /// Default is `.back`. - case devicePosition(AVCaptureDevice.Position) + /// The type of camera to be used when camera is being flipped. + /// Default is `.frontCamera`. + case flipCameraDevice(CameraDevice) /// Will define how the layer will display the player's visual content. /// Default is `.resizeAspectFill`. @@ -61,8 +61,8 @@ public class 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 cameraDevice: CameraDevice = .backWideCamera + public var flipCameraDevice: CameraDevice = .frontCamera public var videoGravity: AVLayerVideoGravity = .resizeAspectFill public var maxVideoDuration: CMTime = .positiveInfinity public var pinchToZoomEnabled: Bool = false @@ -79,10 +79,10 @@ public class CameraComponentParsedOptions { 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 .cameraDevice(let cameraDevice): + self.cameraDevice = cameraDevice + case .flipCameraDevice(let flipCameraDevice): + self.flipCameraDevice = flipCameraDevice case .videoGravity(let videoGravity): self.videoGravity = videoGravity case .maxVideoDuration(let maxVideoDuration): diff --git a/Sources/CameraKage/General/Settings/CameraDevice.swift b/Sources/CameraKage/General/Settings/CameraDevice.swift new file mode 100644 index 0000000..994b6e1 --- /dev/null +++ b/Sources/CameraKage/General/Settings/CameraDevice.swift @@ -0,0 +1,45 @@ +// +// CameraDevice.swift +// +// +// Created by Lobont Andrei on 05.06.2023. +// + +import Foundation + +public enum CameraDevice { + /// A built-in front side wide angle camera device. These devices are suitable for general purpose use. + case frontCamera + + /// A built-in back side wide angle camera device. These devices are suitable for general purpose use. + case backWideCamera + + /// A built-in camera device with a longer focal length than a wide angle camera. Not available on all phones. + case backTelephotoCamera + + /// A built-in camera device with a shorter focal length than a wide angle camera. Not available on all phones. + case backUltraWideCamera + + /// A device that consists of two fixed focal length cameras, one wide and one telephoto. Not available on all phones. + /// A device of this device type supports the following features: + /// - Auto switching from one camera to the other when zoom factor, light level, and focus position allow this. + /// - Higher quality zoom for still captures by fusing images from both cameras. + /// - Delivery of photos from constituent devices (wide and telephoto cameras) via a single photo capture request. + /// Even when locked, exposure duration, ISO, aperture, white balance gains, or lens position may change when the device switches from one camera to the other. The overall exposure, white balance, and focus position however should be consistent. + case backDualCamera + + /// A device that consists of two fixed focal length cameras, one ultra wide and one wide angle. Not available on all phones. + /// A device of this device type supports the following features: + /// - Auto switching from one camera to the other when zoom factor, light level, and focus position allow this. + /// - Delivery of photos from constituent devices (ultra wide and wide) via a single photo capture request. + + /// Even when locked, exposure duration, ISO, aperture, white balance gains, or lens position may change when the device switches from one camera to the other. The overall exposure, white balance, and focus position however should be consistent. + case backWideDualCamera + + /// A device that consists of three fixed focal length cameras, one ultra wide, one wide angle, and one telephoto. Not available on all phones. + /// A device of this device type supports the following features: + /// - Auto switching from one camera to the other when zoom factor, light level, and focus position allow this. + /// - Delivery of photos from constituent devices (ultra wide, wide and telephoto cameras) via a single photo capture request. + /// Even when locked, exposure duration, ISO, aperture, white balance gains, or lens position may change when the device switches from one camera to the other. The overall exposure, white balance, and focus position however should be consistent. + case backTripleCamera +} diff --git a/Sources/CameraKage/General/Settings/MediaType.swift b/Sources/CameraKage/General/Settings/MediaType.swift index b8e3b1d..d43b2e2 100644 --- a/Sources/CameraKage/General/Settings/MediaType.swift +++ b/Sources/CameraKage/General/Settings/MediaType.swift @@ -1,13 +1,13 @@ // // MediaType.swift -// CameraKage +// // -// Created by Lobont Andrei on 22.05.2023. +// Created by Lobont Andrei on 07.06.2023. // import Foundation enum MediaType { - case photo + case audio case video } diff --git a/Sources/CameraKage/General/Settings/OutputType.swift b/Sources/CameraKage/General/Settings/OutputType.swift new file mode 100644 index 0000000..c355d03 --- /dev/null +++ b/Sources/CameraKage/General/Settings/OutputType.swift @@ -0,0 +1,13 @@ +// +// OutputType.swift +// CameraKage +// +// Created by Lobont Andrei on 22.05.2023. +// + +import Foundation + +enum OutputType { + case photo + case video +} diff --git a/Tests/CameraKageTests/CameraKageTests.swift b/Tests/CameraKageTests/CameraKageTests.swift index f0510ad..0f797dd 100644 --- a/Tests/CameraKageTests/CameraKageTests.swift +++ b/Tests/CameraKageTests/CameraKageTests.swift @@ -2,92 +2,129 @@ import XCTest @testable import CameraKage final class CameraKageTests: XCTestCase { - private var delegatesManager: DelegatesManagerProtocol! - private var permissionsManagerMock: PermissionsManagerProtocol! - - override func setUp() { - super.setUp() - delegatesManager = DelegatesManagerMock() - permissionsManagerMock = PermissionManagerMock() - } - - override func tearDown() { - super.tearDown() - delegatesManager = nil - permissionsManagerMock = nil + func test_delegatesManager_registerDelegate() { + let managerMock = createDelegatesManagerMock() + let sut = makeSUT(delegatesManager: managerMock) + + XCTAssertEqual(managerMock.delegates.count, 0, "Mock was just created, so count should be 0.") + + let delegateMock = createDelegateMock() + sut.registerDelegate(delegateMock) + XCTAssertEqual(managerMock.delegates.count, 1) } - func testDelegateRegistration() { - XCTAssertEqual(delegatesManager.delegates.allObjects.count, 0) + func test_delegatesManager_unregisterDelegate() { + let managerMock = createDelegatesManagerMock() + let sut = makeSUT(delegatesManager: managerMock) + + XCTAssertEqual(managerMock.delegates.count, 0, "Mock was just created, so count should be 0.") - let firstDelegate = CameraKageDelegateMock() - let secondDelegate = CameraKageDelegateMock() - delegatesManager.registerDelegate(firstDelegate) - delegatesManager.registerDelegate(secondDelegate) + let delegateMock = createDelegateMock() + sut.unregisterDelegate(delegateMock) + XCTAssertEqual(managerMock.delegates.count, 0, "Delegate mock wasn't registered as a delegate so count was never modified") - XCTAssertEqual(delegatesManager.delegates.allObjects.count, 2) + sut.registerDelegate(delegateMock) + XCTAssertEqual(managerMock.delegates.count, 1) - delegatesManager.unregisterDelegate(firstDelegate) - XCTAssertEqual(delegatesManager.delegates.allObjects.count, 1) + sut.unregisterDelegate(delegateMock) + XCTAssertEqual(managerMock.delegates.count, 0) } - func testDelegateInvocation() { - let firstDelegate = CameraKageDelegateMock() - let secondDelegate = CameraKageDelegateMock() - delegatesManager.registerDelegate(firstDelegate) - XCTAssertFalse(firstDelegate.invoked) - XCTAssertFalse(secondDelegate.invoked) - delegatesManager.invokeDelegates { delegate in + func test_delegatesManager_delegatesInvocation() { + let managerMock = createDelegatesManagerMock() + let delegateMock = createDelegateMock() + let sut = makeSUT(delegatesManager: managerMock) + + XCTAssertFalse(delegateMock.invoked, "Delegate mock was just created, so it shouldn't have been invoked yet.") + + sut.registerDelegate(delegateMock) + managerMock.invokeDelegates { delegate in let delegate = delegate as? CameraKageDelegateMock XCTAssertEqual(delegate?.invoked, true) } - delegatesManager.registerDelegate(secondDelegate) - XCTAssertTrue(firstDelegate.invoked) - XCTAssertFalse(secondDelegate.invoked) + + XCTAssertTrue(delegateMock.invoked) } - func testGetCameraPermissionStatusWithCallback() { - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .video), .denied) - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .audio), .denied) - permissionsManagerMock.requestAccess(for: .video) { granted in + func test_permissionsManager_requestVideoPermission_withCompletion() { + let managerMock = createPermisssionsManagerMock() + let sut = makeSUT(permissionsManager: managerMock) + + XCTAssertEqual(managerMock.getAuthorizationStatus(for: .video), .notDetermined, "Request hasn't been made, so status should be notDetermined") + + sut.requestCameraPermission { granted in XCTAssertTrue(granted) - XCTAssertEqual(self.permissionsManagerMock.getAuthorizationStatus(for: .video), .authorized) - XCTAssertEqual(self.permissionsManagerMock.getAuthorizationStatus(for: .audio), .denied) } + XCTAssertEqual(managerMock.getAuthorizationStatus(for: .video), .authorized) } - func testGetCameraPermissionStatus() async { - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .video), .denied) - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .audio), .denied) - let granted = await permissionsManagerMock.requestAccess(for: .video) + func test_permissionsManager_requestVideoPermission_withConcurrency() async { + let managerMock = createPermisssionsManagerMock() + let sut = makeSUT(permissionsManager: managerMock) + + XCTAssertEqual(managerMock.getAuthorizationStatus(for: .video), .notDetermined, "Request hasn't been made, so status should be notDetermined") + + let granted = await sut.requestCameraPermission() XCTAssertTrue(granted) - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .video), .authorized) - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .audio), .denied) + XCTAssertEqual(managerMock.getAuthorizationStatus(for: .video), .authorized) } - func testGetMicrophonePermissionStatusWithCallback() { - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .video), .denied) - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .audio), .denied) - permissionsManagerMock.requestAccess(for: .audio) { granted in + func test_permissionsManager_requestAudioPermission_withCompletion() { + let managerMock = createPermisssionsManagerMock() + let sut = makeSUT(permissionsManager: managerMock) + + XCTAssertEqual(managerMock.getAuthorizationStatus(for: .audio), .notDetermined, "Request hasn't been made, so status should be notDetermined") + + sut.requestMicrophonePermission { granted in XCTAssertTrue(granted) - XCTAssertEqual(self.permissionsManagerMock.getAuthorizationStatus(for: .video), .denied) - XCTAssertEqual(self.permissionsManagerMock.getAuthorizationStatus(for: .audio), .authorized) } + XCTAssertEqual(managerMock.getAuthorizationStatus(for: .audio), .authorized) } - func testGetMicrophonePermissionStatus() async { - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .audio), .denied) - let granted = await permissionsManagerMock.requestAccess(for: .audio) + func test_permissionsManager_requestAudioPermission_withConcurrency() async { + let managerMock = createPermisssionsManagerMock() + let sut = makeSUT(permissionsManager: managerMock) + + XCTAssertEqual(managerMock.getAuthorizationStatus(for: .audio), .notDetermined, "Request hasn't been made, so status should be notDetermined") + + let granted = await sut.requestMicrophonePermission() XCTAssertTrue(granted) - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .video), .denied) - XCTAssertEqual(permissionsManagerMock.getAuthorizationStatus(for: .audio), .authorized) + XCTAssertEqual(managerMock.getAuthorizationStatus(for: .audio), .authorized) } } -extension XCTestCase { - public func trackMemoryLeaks(_ instance: AnyObject, file: StaticString = #filePath, line: UInt = #line) { - addTeardownBlock { [weak instance] in - XCTAssertNil(instance, "Instance should have been deallocated. Potential memory leak.", file: file, line: line) - } +extension CameraKageTests { + func makeSUT(delegatesManager: DelegatesManagerMock, + permissionsManager: PermissionManagerMock, + cameraComposer: CameraComposer) -> CameraKage { + let sut = CameraKage(permissionManager: permissionsManager, + delegatesManager: delegatesManager, + cameraComposer: cameraComposer) + trackMemoryLeaks(sut) + return sut + } + + func makeSUT(delegatesManager: DelegatesManagerMock) -> CameraKage { + let sut = CameraKage(delegatesManager: delegatesManager) + trackMemoryLeaks(sut) + return sut + } + + func makeSUT(permissionsManager: PermissionManagerMock) -> CameraKage { + let sut = CameraKage(permissionManager: permissionsManager) + trackMemoryLeaks(sut) + return sut + } + + func createDelegatesManagerMock() -> DelegatesManagerMock { + DelegatesManagerMock() + } + + func createDelegateMock() -> CameraKageDelegateMock { + CameraKageDelegateMock() + } + + func createPermisssionsManagerMock() -> PermissionManagerMock { + PermissionManagerMock() } } diff --git a/Tests/CameraKageTests/PermissionManagerMock.swift b/Tests/CameraKageTests/PermissionManagerMock.swift index d3b5db9..52aa442 100644 --- a/Tests/CameraKageTests/PermissionManagerMock.swift +++ b/Tests/CameraKageTests/PermissionManagerMock.swift @@ -5,44 +5,43 @@ // Created by Lobont Andrei on 29.05.2023. // -import AVFoundation +import Foundation @testable import CameraKage -class PermissionManagerMock: PermissionsManagerProtocol { - private var authorizedVideo = false - private var authorizedAudio = false +final class PermissionManagerMock: PermissionsManagerProtocol { + private var authorizedVideo: Bool? + private var authorizedAudio: Bool? - func getAuthorizationStatus(for media: AVMediaType) -> PermissionStatus { + func getAuthorizationStatus(for media: MediaType) -> PermissionStatus { switch media { - case .audio: return authorizedAudio ? .authorized : .denied - case .video: return authorizedVideo ? .authorized : .denied - default: return .notDetermined // not using other media type yet + case .audio: + guard let authorizedAudio else { return .notDetermined } + return authorizedAudio ? .authorized : .denied + case .video: + guard let authorizedVideo else { return .notDetermined } + return authorizedVideo ? .authorized : .denied } } - func requestAccess(for media: AVMediaType) async -> Bool { + func requestAccess(for media: MediaType) async -> Bool { switch media { case .audio: authorizedAudio = true - return authorizedAudio + return authorizedAudio ?? true case .video: authorizedVideo = true - return authorizedVideo - default: - return false // not using other media type yet + return authorizedVideo ?? true } } - func requestAccess(for media: AVMediaType, completion: @escaping ((Bool) -> Void)) { + func requestAccess(for media: MediaType, completion: @escaping ((Bool) -> Void)) { switch media { case .audio: authorizedAudio = true - completion(authorizedAudio) + completion(authorizedAudio ?? true) case .video: authorizedVideo = true - completion(authorizedVideo) - default: - break // not using other media type yet + completion(authorizedVideo ?? true) } } } diff --git a/Tests/CameraKageTests/Utils/XCTestCase+TrackMemoryLeak.swift b/Tests/CameraKageTests/Utils/XCTestCase+TrackMemoryLeak.swift new file mode 100644 index 0000000..0b8077a --- /dev/null +++ b/Tests/CameraKageTests/Utils/XCTestCase+TrackMemoryLeak.swift @@ -0,0 +1,16 @@ +// +// XCTestCase+TrackMemoryLeak.swift +// +// +// Created by Lobont Andrei on 08.06.2023. +// + +import XCTest + +extension XCTestCase { + func trackMemoryLeaks(_ instance: AnyObject, file: StaticString = #filePath, line: UInt = #line) { + addTeardownBlock { [weak instance] in + XCTAssertNil(instance, "Instance should have been deallocated. Potential memory leak.", file: file, line: line) + } + } +}