From 211845aa2f6b0ad19e919a2c4902ae6ccfbed59d Mon Sep 17 00:00:00 2001 From: YoungBin Lee Date: Sun, 25 Jun 2023 21:09:43 +0900 Subject: [PATCH] release v0.2.0 (#13) * Remove setting from capture method * Add context data structure * Implement video context * Remove video related code from public session * Implement photo context * Refactor file structure - with comments * Implement photo fetching * Fix photo context to use new setting each time * Update cache test cases * Add cache storage tests * Split video and photo fetching logic * Add generator tests * Move fetch method location * Remove unnecessary data * Separate video and photo context * Fix workflow and test cases * Fix error * Update readme * Add orientation getter * Fix thumbnail setting --- .github/workflows/unit-test.yml | 5 +- .swiftlint.yml | 8 +- README.md | 142 ++-- Scripts/gen-mocks.sh | 4 +- Sources/Aespa/AespaOption.swift | 13 +- Sources/Aespa/AespaSession.swift | 649 +++++------------- Sources/Aespa/Context/AespaPhotoContext.swift | 120 ++++ Sources/Aespa/Context/AespaVideoContext.swift | 142 ++++ Sources/Aespa/Context/Context.swift | 547 +++++++++++++++ .../Aespa/Core/AespaCoreAlbumManager.swift | 1 - Sources/Aespa/Core/AespaCoreFileManager.swift | 113 ++- .../Photos+AespaRepresentable.swift | 6 +- Sources/Aespa/Data/File/PhotoFile.swift | 58 ++ Sources/Aespa/Data/{ => File}/VideoFile.swift | 37 +- Sources/Aespa/Data/PhotoFile.swift | 30 - ...hingProxy.swift => FileCachingProxy.swift} | 73 +- .../Aespa/Data/Proxy/URLCacheStorage.swift | 12 +- Sources/Aespa/Processor/AespaProcessing.swift | 8 +- .../Asset/PhotoAssetAdditionProcessor.swift | 6 +- .../{Video => }/Album/AlbumImporter.swift | 0 .../Authorization/AuthorizationChecker.swift | 0 .../Aespa/Util/File/FilePathProvider.swift | 60 ++ .../Aespa/Util/File/PhotoFileGenerator.swift | 30 + .../{Video => }/File/VideoFileGenerator.swift | 13 +- .../Util/Video/File/PhotoFileGenerator.swift | 18 - .../Video/File/VideoFilePathProvider.swift | 45 -- Tests/TestHostApp.xcodeproj/project.pbxproj | 28 +- .../xcschemes/TestHostApp.xcscheme | 11 +- Tests/TestHostApp/TestHostAppApp.swift | 1 + Tests/Tests/Data/FileCachingProxyTests.swift | 210 ++++++ Tests/Tests/Data/URLCacheStorageTests.swift | 62 ++ .../Data/VideoFileCachingProxyTests.swift | 218 ------ Tests/Tests/Mock/{Video => Asset}/video.mp4 | Bin Tests/Tests/Mock/MockImage.swift | 25 + Tests/Tests/Mock/{Video => }/MockVideo.swift | 0 Tests/Tests/Util/AlbumUtilTests.swift | 1 - Tests/Tests/Util/FileUtilTests.swift | 8 +- Tests/Tests/Util/GeneratorTests.swift | 70 ++ 38 files changed, 1793 insertions(+), 981 deletions(-) create mode 100644 Sources/Aespa/Context/AespaPhotoContext.swift create mode 100644 Sources/Aespa/Context/AespaVideoContext.swift create mode 100644 Sources/Aespa/Context/Context.swift create mode 100644 Sources/Aespa/Data/File/PhotoFile.swift rename Sources/Aespa/Data/{ => File}/VideoFile.swift (85%) delete mode 100644 Sources/Aespa/Data/PhotoFile.swift rename Sources/Aespa/Data/Proxy/{VideoFileCachingProxy.swift => FileCachingProxy.swift} (55%) rename Sources/Aespa/Util/{Video => }/Album/AlbumImporter.swift (100%) rename Sources/Aespa/Util/{Video => }/Authorization/AuthorizationChecker.swift (100%) create mode 100644 Sources/Aespa/Util/File/FilePathProvider.swift create mode 100644 Sources/Aespa/Util/File/PhotoFileGenerator.swift rename Sources/Aespa/Util/{Video => }/File/VideoFileGenerator.swift (71%) delete mode 100644 Sources/Aespa/Util/Video/File/PhotoFileGenerator.swift delete mode 100644 Sources/Aespa/Util/Video/File/VideoFilePathProvider.swift create mode 100644 Tests/Tests/Data/FileCachingProxyTests.swift create mode 100644 Tests/Tests/Data/URLCacheStorageTests.swift delete mode 100644 Tests/Tests/Data/VideoFileCachingProxyTests.swift rename Tests/Tests/Mock/{Video => Asset}/video.mp4 (100%) create mode 100644 Tests/Tests/Mock/MockImage.swift rename Tests/Tests/Mock/{Video => }/MockVideo.swift (100%) create mode 100644 Tests/Tests/Util/GeneratorTests.swift diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index ef63683..747550a 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -2,7 +2,7 @@ name: Unit testing and collect result infomations on: pull_request: - types: [synchronize] + types: [opened, synchronize] concurrency: group: unit-test-${{ github.head_ref }} @@ -35,6 +35,7 @@ jobs: - name: Test run: | + set -o pipefail ROOT_PATH="./" # Now do test @@ -56,4 +57,4 @@ jobs: if [ $? -ne 0 ]; then echo "❌ Error: Tests failed." exit 1 - fi + fi \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml index 4c54959..8e1ffaa 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -15,14 +15,14 @@ analyzer_rules: - explicit_self included: - - Sources/* + - Sources excluded: - - Tests/* + - Tests # If true, SwiftLint will not fail if no lintable files are found. allow_zero_lintable_files: false -force_cast: warning # implicitly +force_cast: error # implicitly force_try: severity: warning # explicitly # implicitly @@ -38,6 +38,6 @@ identifier_name: allowed_symbols: ["_"] missing_docs: - included: Sources/* + severity: warning reporter: "xcode" diff --git a/README.md b/README.md index 8275770..c1d3c0e 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ try await Aespa.configure() - [Requirements](#Requirements) - [Getting Started](#Getting-Started) - [Implementation Examples](#Implementation-Examples) - - [Start & Stop Recording](#Start-&-Stop-Recording) - - [Subscribing Publisher](#Subscribing-Publisher) + - [Configuration](#Configuration) + - [Recording & Capture](#Recording-&-Capture) - [SwiftUI Integration](#SwiftUI-Integration) - [Example Usage](#Example-Usage) - [Contributing](#Contributing) @@ -118,22 +118,41 @@ graph LR; ## Functionality -Aespa offers the following functionalities for managing a video recording session: - -| Function | Description | -|------------------------------|---------------------------------------------------| -| `startRecording` | Initiates the recording of a video session. | -| `stopRecording` | Terminates the current video recording session and attempts to save the video file. | -| `mute` | Mutes the audio input for the video recording session. | -| `unmute` | Restores the audio input for the video recording session. | -| `setOrientation` | Adjusts the orientation for the video recording session. | -| `setPosition` | Changes the camera position for the video recording session. | -| `setQuality` | Alters the video quality preset for the recording session. | -| `setStabilization` | Modifies the stabilization mode for the video recording session. | -| `zoom` | Adjusts the zoom factor for the video recording session. | -| `setAutofocusing` | Modifies the autofocusing mode for the video recording session. | -| `fetchVideoFiles` | Fetch a list of recorded video files. | -| `doctor` | Check if essential conditions for recording are satisfied. | + +> **Note** +> +> You can access our **official documentation** for the most comprehensive and up-to-date explanations in [here](https://enebin.github.io/Aespa/documentation/aespa/) + +| Common | Description | +|----------------------------------|------------------------------------------------------------------------------------------------------------------| +| ✨ `zoom` | Modifies the zoom factor. | +| ✨ `setPosition` | Changes the camera position. | +| `setOrientation` | Modifies the orientation. | +| `setAutofocusing` | Alters the autofocusing mode. | +| `setQuality` | Adjusts the video quality preset for the recording session. | +| `doctor` | Checks if essential conditions to start recording are satisfied. | +| `previewLayerPublisher` | Responsible for emitting updates to the preview layer. | + +| Video | Description | +|----------------------------------|------------------------------------------------------------------------------------------------------------------| +| ✨ `startRecording` | Initiates the recording of a video session. | +| ✨ `stopRecording` | Terminates the current video recording session and attempts to save the video file. | +| `mute` | Mutes the audio input. | +| `unmute` | Restores the audio input. | +| `setStabilization` | Alters the stabilization mode. | +| `setTorch` | Adjusts the torch mode and level. | +| `customize` | Customizes the session with a specific tuning configuration. | +| ✨ `fetchVideoFiles` | Fetches a list of recorded video files. | +| `videoFilePublisher` | Emits a `Result` object containing a latest video file data. | + +| Photo | Description | +|----------------------------------|------------------------------------------------------------------------------------------------------------------| +| ✨ `capturePhoto` | Capture a photo and returns a result image file. | +| ✨ `setFlashMode` | Sets the flash mode for the photo capture session. | +| `redEyeReduction` | Enables or disables red-eye reduction for the photo capture session. | +| `customize` | Customizes the photo capture session with a specific `AVCapturePhotoSettings`. | +| ✨ `fetchPhotoFiles` | Fetches a list of captured photos files. | +| `photoFilePublisher` | Emits a `Result` object containing a latest image file data. | ## Installation ### Swift Package Manager (SPM) @@ -141,15 +160,21 @@ Follow these steps to install **Aespa** using SPM: 1. From within Xcode 13 or later, choose `File` > `Swift Packages` > `Add Package Dependency`. 2. At the next screen, enter the URL for the **Aespa** repository in the search bar then click `Next`. - ``` Text - https://github.com/enebin/Aespa.git - ``` +``` Text +https://github.com/enebin/Aespa.git +``` 3. For the `Version rule`, select `Up to Next Minor` and specify the current Aespa version then click `Next`. 4. On the final screen, select the `Aespa` library and then click `Finish`. **Aespa** should now be integrated into your project 🚀 ## Usage + +> **Note** +> +> We offer an extensively detailed and ready-to-use code base for a SwiftUI app that showcases most of the package's features. +> You can access it [here](https://github.com/enebin/Aespa-iOS). + ### Requirements - Swift 5.5+ - iOS 14.0+ @@ -166,49 +191,39 @@ Task(priority: .background) { } ``` > **Warning** +> > Please ensure to call `configure` within a background execution context. Neglecting to do so may lead to significantly reduced responsiveness in your application. ([reference](https://developer.apple.com/documentation/avfoundation/avcapturesession/1388185-startrunning)) - -and so on. You can find the latest documetation in [here](https://enebin.github.io/Aespa/documentation/aespa/) -.Implementation examples are decribed in [here](##Implementation-Exapmles) - - ## Implementation Exapmles -### Start & stop recording +### Configuration ``` Swift -do { - try aespaSession - .setStabilization(mode: .auto) - .setPosition(to: .front) - .setQuality(to: .hd1920x1080) - .mute() - .startRecording() -} catch { - print("Failed to start recording") -} - -// Later... -try? aespaSession.stopRecording() - +// Common setting +aespaSession + .setAutofocusing(mode: .continuousAutoFocus) + .setOrientation(to: .portrait) + .setQuality(to: .high) + .customize(WideColorCameraTuner()) + +// Photo-only setting +aespaSession + .setFlashMode(to: .on) + .redEyeReduction(enabled: true) + +// Video-only setting +aespaSession + .mute() + .setStabilization(mode: .auto) ``` -### Subscribing publihser -``` Swift -// Subscribe file publisher -aespaSession.videoFilePublisher - .receive(on: DispatchQueue.global(qos: .utility)) - .sink { [weak self] status in - guard let self else { return } - switch status { - case .success(let file): - // Handle file - // ex) return file.thumbnailImage - case .failure(let error): - // Handle error - // ex)print(error) - } - } - .store(in: &subsriptions) +### Recording & Capture +``` Swift +// Start recording +aespaSession.startRecording() +// Later... stop recording +aespaSession.stopRecording() + +// Capture photo +aespaSession.capturePhoto() ``` ## SwiftUI Integration @@ -249,13 +264,12 @@ class VideoContentViewModel: ObservableObject { Task(priority: .background) { do { try await Aespa.configure() - try aespaSession - .mute() + aespaSession .setAutofocusing(mode: .continuousAutoFocus) - .setStabilization(mode: .auto) .setOrientation(to: .portrait) .setQuality(to: .high) - .customize(WideColorCameraTuner()) + + // Other settings ... } catch let error { print(error) @@ -264,10 +278,10 @@ class VideoContentViewModel: ObservableObject { } } ``` -> **Note**: You can find the demo app in here([Aespa-iOS](https://github.com/enebin/Aespa-iOS)) -> **Note**: In UIKit, you can access the preview through the `previewLayer` property of `AespaSession`. +> **Note** > +> In `UIKit`, you can access the preview through the `previewLayer` property of `AespaSession`. > For more details, refer to the [AVCaptureVideoPreviewLayer](https://developer.apple.com/documentation/avfoundation/avcapturevideopreviewlayer) in the official Apple documentation. ## Contributing diff --git a/Scripts/gen-mocks.sh b/Scripts/gen-mocks.sh index 12d9256..573c520 100755 --- a/Scripts/gen-mocks.sh +++ b/Scripts/gen-mocks.sh @@ -15,7 +15,7 @@ PROJECT_NAME="Aespa" TESTER_NAME="TestHostApp" PACKAGE_SOURCE_PATH="${ROOT_PATH}/Sources/Aespa" OUTPUT_FILE="${ROOT_PATH}/Tests/Tests/Mock/GeneratedMocks.swift" -SWIFT_FILES=$(find "$PACKAGE_SOURCE_PATH" -type f -name "*.swift" -print0 | xargs -0) +SWIFT_FILES=$(find "$PACKAGE_SOURCE_PATH" -type f -name "*.swift" -not -path "*/Context/*" -print0 | xargs -0) echo "✅ Generated Mocks File = ${OUTPUT_FILE}" echo "✅ Mocks Input Directory = ${PACKAGE_SOURCE_PATH}" @@ -28,4 +28,4 @@ if [ $? -ne 0 ]; then exit 1 fi -echo "✅ Generating mock was successful" \ No newline at end of file +echo "✅ Generating mock was successful" diff --git a/Sources/Aespa/AespaOption.swift b/Sources/Aespa/AespaOption.swift index c140640..bfc50bb 100644 --- a/Sources/Aespa/AespaOption.swift +++ b/Sources/Aespa/AespaOption.swift @@ -55,8 +55,14 @@ public extension AespaOption { /// `Asset` provides options for configuring the video assets, /// such as the album name, file naming rule, and file extension. struct Asset { - /// The name of the album where recorded videos will be saved. + /// The name of the album where recorded assets will be saved. let albumName: String + + /// The name of the album where recorded videos will be saved. + let videoDirectoryName: String + + /// The name of the album where recorded photos will be saved. + let photoDirectoryName: String /// A `Boolean` flag that determines to use in-memory cache for `VideoFile` /// @@ -71,11 +77,16 @@ public extension AespaOption { init( albumName: String, + videoDirectoryName: String = "video", + photoDirectoryName: String = "photo", useVideoFileCache: Bool = true, fileExtension: FileExtension = .mp4, fileNameHandler: @escaping FileNamingRule = FileNamingRulePreset.Timestamp().rule ) { self.albumName = albumName + self.videoDirectoryName = videoDirectoryName + self.photoDirectoryName = photoDirectoryName + self.useVideoFileCache = useVideoFileCache self.fileExtension = fileExtension.rawValue self.fileNameHandler = fileNameHandler diff --git a/Sources/Aespa/AespaSession.swift b/Sources/Aespa/AespaSession.swift index 3973c58..6d0689f 100644 --- a/Sources/Aespa/AespaSession.swift +++ b/Sources/Aespa/AespaSession.swift @@ -22,14 +22,18 @@ import AVFoundation open class AespaSession { private let option: AespaOption private let coreSession: AespaCoreSession - private let recorder: AespaCoreRecorder - private let camera: AespaCoreCamera private let fileManager: AespaCoreFileManager private let albumManager: AespaCoreAlbumManager + + private let recorder: AespaCoreRecorder + private let camera: AespaCoreCamera - private let photoFileBufferSubject: CurrentValueSubject?, Never> - private let videoFileBufferSubject: CurrentValueSubject?, Never> private let previewLayerSubject: CurrentValueSubject + + private var photoSetting: AVCapturePhotoSettings + + private var videoContext: AespaVideoContext! + private var photoContext: AespaPhotoContext! /// A `UIKit` layer that you use to display video as it is being captured by an input device. /// @@ -63,35 +67,40 @@ open class AespaSession { self.camera = camera self.fileManager = fileManager self.albumManager = albumManager - - self.videoFileBufferSubject = .init(nil) - self.photoFileBufferSubject = .init(nil) + self.previewLayerSubject = .init(nil) - + + self.photoSetting = .init() self.previewLayer = AVCaptureVideoPreviewLayer(session: session) - - // Add first video file to buffer if it exists - if let firstVideoFile = fileManager.fetch(albumName: option.asset.albumName, count: 1).first { - videoFileBufferSubject.send(.success(firstVideoFile)) - } - } - - // MARK: - vars + + setupContext() + } + + private func setupContext() { + self.photoContext = AespaPhotoContext( + coreSession: coreSession, + camera: camera, + albumManager: albumManager, + fileManager: fileManager, + option: option) + + self.videoContext = AespaVideoContext( + commonContext: self, + coreSession: coreSession, + recorder: recorder, + albumManager: albumManager, + fileManager: fileManager, + option: option) + } + + // MARK: - Public variables /// This property exposes the underlying `AVCaptureSession` that `Aespa` currently utilizes. /// - /// While you can directly interact with this object, it is strongly recommended to avoid modifications - /// that could yield unpredictable behavior. - /// If you require custom configurations, - /// consider utilizing the `custom` function we offer whenever possible. - public var captureSession: AVCaptureSession { - return coreSession - } - - /// This property reflects the current state of audio input. - /// - /// If it returns `true`, the audio input is currently muted. - public var isMuted: Bool { - coreSession.audioDeviceInput == nil + /// - Warning: While you can directly interact with this object, it is strongly recommended to avoid modifications + /// that could yield unpredictable behavior. + /// If you require custom configurations, consider utilizing the `custom` function we offer whenever possible. + public var avCaptureSession: AVCaptureSession { + coreSession } /// This property provides the maximum zoom factor supported by the active video device format. @@ -105,37 +114,19 @@ open class AespaSession { guard let videoDeviceInput = coreSession.videoDeviceInput else { return nil } return videoDeviceInput.device.videoZoomFactor } - - /// This publisher is responsible for emitting `VideoFile` objects resulting from completed recordings. - /// - /// In the case of an error, it logs the error before forwarding it wrapped in a `Result.failure`. - /// If you don't want to show logs, set `enableLogging` to `false` from `AespaOption.Log` - /// - /// - Returns: `VideoFile` wrapped in a `Result` type. - public var videoFilePublisher: AnyPublisher, Never> { - videoFileBufferSubject.handleEvents(receiveOutput: { status in - if case .failure(let error) = status { - Logger.log(error: error) - } - }) - .compactMap({ $0 }) - .eraseToAnyPublisher() + + /// This property reflects the current zoom factor applied to the video device. + public var currentFocusMode: AVCaptureDevice.FocusMode? { + guard let videoDeviceInput = coreSession.videoDeviceInput else { return nil } + return videoDeviceInput.device.focusMode } - - /// The publisher that broadcasts the result of a photo file operation. - /// It emits a `Result` object containing a `PhotoFile` on success or an `Error` on failure, - /// and never fails itself. This can be used to observe the photo capturing process and handle - /// the results asynchronously. - public var photoFilePublisher: AnyPublisher, Never> { - photoFileBufferSubject.handleEvents(receiveOutput: { status in - if case .failure(let error) = status { - Logger.log(error: error) - } - }) - .compactMap({ $0 }) - .eraseToAnyPublisher() + + /// This property reflects the session's current orientation. + public var currentOrientation: AVCaptureVideoOrientation? { + guard let connection = coreSession.connections.first else { return nil } + return connection.videoOrientation } - + /// This publisher is responsible for emitting updates to the preview layer. /// /// A log message is printed to the console every time a new layer is pushed. @@ -148,492 +139,176 @@ open class AespaSession { .eraseToAnyPublisher() } - // MARK: - Methods - // MARK: No throws for convenience - not recommended! - /// Starts the recording of a video session. - /// - /// If an error occurs during the operation, the error is logged. + // MARK: - Utilities + /// Checks if essential conditions to start recording are satisfied. + /// This includes checking for capture authorization, if the session is running, + /// if there is an existing connection and if a device is attached. /// - /// - Note: If auto video orientation is enabled, - /// it sets the orientation according to the current device orientation. - public func startRecording() { - do { - try startRecordingWithError() - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation + /// - Throws: `AespaError.permission` if capture authorization is denied. + /// - Throws: `AespaError.session` if the session is not running, + /// cannot find a connection, or cannot find a device. + public func doctor() async throws { + // Check authorization status + guard + case .permitted = await AuthorizationChecker.checkCaptureAuthorizationStatus() + else { + throw AespaError.permission(reason: .denied) } - } - /// Stops the current video recording session and attempts to save the video file to the album. - /// - /// Any errors that occur during the process are captured and logged. - /// - /// - Parameter completionHandler: A closure that handles the result of the operation. - /// It's called with a `Result` object that encapsulates either a `VideoFile` instance. - /// - /// - Note: It is recommended to use the ``stopRecording() async throws`` - /// for more straightforward error handling. - public func stopRecording( - _ completionHandler: @escaping (Result) -> Void = { _ in } - ) { - Task(priority: .utility) { - do { - let videoFile = try await self.stopRecordingWithError() - return completionHandler(.success(videoFile)) - } catch let error { - Logger.log(error: error) - return completionHandler(.failure(error)) - } + guard coreSession.isRunning else { + throw AespaError.session(reason: .notRunning) } - } - /// Asynchronously captures a photo using the specified `AVCapturePhotoSettings`. - /// - /// This function utilizes the `captureWithError(setting:)` function to perform the actual photo capture, - /// while handling any errors that may occur. If the photo capture is successful, it will return a `PhotoFile` - /// object through the provided completion handler. - /// - /// In case of an error during the photo capture process, the error will be logged and also returned via - /// the completion handler. - /// - /// - Parameters: - /// - setting: The `AVCapturePhotoSettings` to use when capturing the photo. - /// - completionHandler: A closure to be invoked once the photo capture process is completed. This - /// closure takes a `Result` type where `Success` contains a `PhotoFile` object and - /// `Failure` contains an `Error` object. By default, the closure does nothing. - /// - public func capturePhoto( - setting: AVCapturePhotoSettings, - _ completionHandler: @escaping (Result) -> Void = { _ in } - ) { - Task(priority: .utility) { - do { - let photoFile = try await self.captureWithError(setting: setting) - return completionHandler(.success(photoFile)) - } catch let error { - Logger.log(error: error) - return completionHandler(.failure(error)) - } + // Check if connection exists + guard coreSession.movieFileOutput != nil else { + throw AespaError.session(reason: .cannotFindConnection) } - } - /// Mutes the audio input for the video recording session. - /// - /// If an error occurs during the operation, the error is logged. - /// - /// - Returns: `AespaSession`, for chaining calls. - @discardableResult - public func mute() -> AespaSession { - do { - try self.muteWithError() - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation + // Check if device is attached + guard coreSession.videoDeviceInput != nil else { + throw AespaError.session(reason: .cannotFindDevice) } - - return self } +} - /// Unmutes the audio input for the video recording session. - /// - /// If an error occurs during the operation, the error is logged. - /// - /// - Returns: `AespaSession`, for chaining calls. - @discardableResult - public func unmute() -> AespaSession { - do { - try self.unmuteWithError() - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation - } - - return self +extension AespaSession: CommonContext { + public var underlyingCommonContext: AespaSession { + self } - - /// Sets the quality preset for the video recording session. - /// - /// - Parameter preset: An `AVCaptureSession.Preset` value indicating the quality preset to be set. - /// - /// If an error occurs during the operation, the error is logged. - /// - /// - Returns: `AespaSession`, for chaining calls. + @discardableResult - public func setQuality(to preset: AVCaptureSession.Preset) -> AespaSession { - do { - try self.setQualityWithError(to: preset) - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation - } - + public func setQualityWithError(to preset: AVCaptureSession.Preset) throws -> AespaSession { + let tuner = QualityTuner(videoQuality: preset) + try coreSession.run(tuner) return self } - - /// Sets the camera position for the video recording session. - /// - /// - Parameter position: An `AVCaptureDevice.Position` value indicating the camera position to be set. - /// - /// If an error occurs during the operation, the error is logged. - /// - /// - Returns: `AespaSession`, for chaining calls. + @discardableResult - public func setPosition(to position: AVCaptureDevice.Position) -> AespaSession { - do { - try self.setPositionWithError(to: position) - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation - } - + public func setPositionWithError(to position: AVCaptureDevice.Position) throws -> AespaSession { + let tuner = CameraPositionTuner(position: position, + devicePreference: option.session.cameraDevicePreference) + try coreSession.run(tuner) return self } - - /// Sets the orientation for the video recording session. - /// - /// - Parameter orientation: An `AVCaptureVideoOrientation` value indicating the orientation to be set. - /// - /// If an error occurs during the operation, the error is logged. - /// - /// - Note: It sets the orientation of the video you are recording, - /// not the orientation of the `AVCaptureVideoPreviewLayer`. - /// - /// - Returns: `AespaSession`, for chaining calls. + @discardableResult - public func setOrientation(to orientation: AVCaptureVideoOrientation) -> AespaSession { - do { - try self.setOrientationWithError(to: orientation) - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation - } - + public func setOrientationWithError(to orientation: AVCaptureVideoOrientation) throws -> AespaSession { + let tuner = VideoOrientationTuner(orientation: orientation) + try coreSession.run(tuner) return self } - - /// Sets the stabilization mode for the video recording session. - /// - /// - Parameter mode: An `AVCaptureVideoStabilizationMode` value - /// indicating the stabilization mode to be set. - /// - /// If an error occurs during the operation, the error is logged. - /// - /// - Returns: `AespaSession`, for chaining calls. + @discardableResult - public func setStabilization(mode: AVCaptureVideoStabilizationMode) -> AespaSession { - do { - try self.setStabilizationWithError(mode: mode) - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation - } - + public func setAutofocusingWithError(mode: AVCaptureDevice.FocusMode) throws -> AespaSession { + let tuner = AutoFocusTuner(mode: mode) + try coreSession.run(tuner) return self } - - /// Sets the autofocusing mode for the video recording session. - /// - /// - Parameter mode: The focus mode for the capture device. - /// - /// If an error occurs during the operation, the error is logged. - /// - /// - Returns: `AespaSession`, for chaining calls. + @discardableResult - public func setAutofocusing(mode: AVCaptureDevice.FocusMode) -> AespaSession { - do { - try self.setAutofocusingWithError(mode: mode) - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation - } - + public func zoomWithError(factor: CGFloat) throws -> AespaSession { + let tuner = ZoomTuner(zoomFactor: factor) + try coreSession.run(tuner) return self } - - /// Sets the zoom factor for the video recording session. - /// - /// - Parameter factor: A `CGFloat` value indicating the zoom factor to be set. - /// - /// If an error occurs during the operation, the error is logged. - /// - /// - Returns: `AespaSession`, for chaining calls. - @discardableResult - public func zoom(factor: CGFloat) -> AespaSession { - do { - try self.zoomWithError(factor: factor) - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation - } - + + public func customizeWithError(_ tuner: T) throws -> AespaSession { + try coreSession.run(tuner) return self } +} - /// Sets the torch mode and level for the video recording session. - /// - /// If an error occurs during the operation, the error is logged. - /// - /// - Parameters: - /// - mode: The desired torch mode (AVCaptureDevice.TorchMode). - /// - level: The desired torch level as a Float between 0.0 and 1.0. - /// - /// - Returns: Returns self, allowing additional settings to be configured. - /// - /// - Note: This function might throw an error if the torch mode is not supported, - /// or the specified level is not within the acceptable range. - @discardableResult - public func setTorch(mode: AVCaptureDevice.TorchMode, level: Float) -> AespaSession { - do { - try self.setTorchWitherror(mode: mode, level: level) - } catch let error { - Logger.log(error: error) // Logs any errors encountered during the operation - } - - return self +extension AespaSession: VideoContext { + public typealias AespaVideoSessionContext = AespaVideoContext + + public var underlyingVideoContext: AespaVideoSessionContext { + videoContext } - - // MARK: - Throwing/// Starts the recording of a video session. - /// - /// - Throws: `AespaError` if the video file path request fails, - /// orientation setting fails, or starting the recording fails. - /// - /// - Note: If `autoVideoOrientation` option is enabled, - /// it sets the orientation according to the current device orientation. - public func startRecordingWithError() throws { - let fileName = option.asset.fileNameHandler() - let filePath = try VideoFilePathProvider.requestFilePath( - from: fileManager.systemFileManager, - directoryName: option.asset.albumName, - fileName: fileName, - extension: "mp4") - - if option.session.autoVideoOrientationEnabled { - try setOrientationWithError(to: UIDevice.current.orientation.toVideoOrientation) - } - - try recorder.startRecording(in: filePath) + + public var videoFilePublisher: AnyPublisher, Never> { + videoContext.videoFilePublisher } - - /// Stops the ongoing video recording session and attempts to add the video file to the album. - /// - /// Supporting `async`, you can use this method in Swift Concurrency's context - /// - /// - Throws: `AespaError` if stopping the recording fails. - public func stopRecordingWithError() async throws -> VideoFile { - let videoFilePath = try await recorder.stopRecording() - let videoFile = VideoFileGenerator.generate(with: videoFilePath, date: Date()) - - try await albumManager.addToAlbum(filePath: videoFilePath) - videoFileBufferSubject.send(.success(videoFile)) - - return videoFile + + public var isRecording: Bool { + videoContext.isRecording } - - /// Asynchronously captures a photo with the specified `AVCapturePhotoSettings`. - /// - /// The captured photo is flattened into a `Data` object, and then added to an album. A `PhotoFile` - /// object is then created using the raw photo data and the current date. This `PhotoFile` is sent - /// through the `photoFileBufferSubject` and then returned to the caller. - /// - /// If any part of this process fails, an `AespaError` is thrown. - /// - /// - Parameter setting: The `AVCapturePhotoSettings` to use when capturing the photo. - /// - Returns: A `PhotoFile` object representing the captured photo. - /// - Throws: An `AespaError` if there is an issue capturing the photo, - /// flattening it into a `Data` object, or adding it to the album. - public func captureWithError(setting: AVCapturePhotoSettings) async throws -> PhotoFile { - let rawPhotoAsset = try await camera.capture(setting: setting) - guard let rawPhotoData = rawPhotoAsset.fileDataRepresentation() else { - throw AespaError.file(reason: .unableToFlatten) - } - - try await albumManager.addToAlbum(imageData: rawPhotoData) - - let photoFile = PhotoFileGenerator.generate(data: rawPhotoData, date: Date()) - photoFileBufferSubject.send(.success(photoFile)) - - return photoFile + + public var isMuted: Bool { + videoContext.isMuted } - - /// Mutes the audio input for the video recording session. - /// - /// - Throws: `AespaError` if the session fails to run the tuner. - /// - /// - Returns: `AespaSession`, for chaining calls. + + public func startRecordingWithError() throws { + try videoContext.startRecordingWithError() + } + @discardableResult - public func muteWithError() throws -> AespaSession { - let tuner = AudioTuner(isMuted: true) - try coreSession.run(tuner) - return self + public func stopRecordingWithError() async throws -> VideoFile { + try await videoContext.stopRecordingWithError() } - - /// Unmutes the audio input for the video recording session. - /// - /// - Throws: `AespaError` if the session fails to run the tuner. - /// - /// - Returns: `AespaSession`, for chaining calls. + @discardableResult - public func unmuteWithError() throws -> AespaSession { - let tuner = AudioTuner(isMuted: false) - try coreSession.run(tuner) - return self + public func muteWithError() throws -> AespaVideoSessionContext { + try videoContext.muteWithError() } - - /// Sets the quality preset for the video recording session. - /// - /// - Parameter preset: An `AVCaptureSession.Preset` value indicating the quality preset to be set. - /// - /// - Throws: `AespaError` if the session fails to run the tuner. - /// - /// - Returns: `AespaSession`, for chaining calls. + @discardableResult - public func setQualityWithError(to preset: AVCaptureSession.Preset) throws -> AespaSession { - let tuner = QualityTuner(videoQuality: preset) - try coreSession.run(tuner) - return self + public func unmuteWithError() throws -> AespaVideoSessionContext { + try videoContext.unmuteWithError() } - - /// Sets the camera position for the video recording session. - /// - /// It refers to `AespaOption.Session.cameraDevicePreference` when choosing the camera device. - /// - /// - Parameter position: An `AVCaptureDevice.Position` value indicating the camera position to be set. - /// - /// - Throws: `AespaError` if the session fails to run the tuner. - /// - /// - Returns: `AespaSession`, for chaining calls. + @discardableResult - public func setPositionWithError(to position: AVCaptureDevice.Position) throws -> AespaSession { - let tuner = CameraPositionTuner(position: position, - devicePreference: option.session.cameraDevicePreference) - try coreSession.run(tuner) - return self + public func setStabilizationWithError(mode: AVCaptureVideoStabilizationMode) throws -> AespaVideoSessionContext { + try videoContext.setStabilizationWithError(mode: mode) } - - /// Sets the orientation for the video recording session. - /// - /// - Parameter orientation: An `AVCaptureVideoOrientation` value indicating the orientation to be set. - /// - /// - Throws: `AespaError` if the session fails to run the tuner. - /// - /// - Returns: `AespaSession`, for chaining calls. - /// - /// - Note: It sets the orientation of the video you are recording, - /// not the orientation of the `AVCaptureVideoPreviewLayer`. + @discardableResult - public func setOrientationWithError(to orientation: AVCaptureVideoOrientation) throws -> AespaSession { - let tuner = VideoOrientationTuner(orientation: orientation) - try coreSession.run(tuner) - return self + public func setTorchWithError(mode: AVCaptureDevice.TorchMode, level: Float) throws -> AespaVideoSessionContext { + try videoContext.setTorchWithError(mode: mode, level: level) + } + + public func fetchVideoFiles(limit: Int) -> [VideoFile] { + videoContext.fetchVideoFiles(limit: limit) } +} - /// Sets the stabilization mode for the video recording session. - /// - /// - Parameter mode: An `AVCaptureVideoStabilizationMode` value - /// indicating the stabilization mode to be set. - /// - /// - Throws: `AespaError` if the session fails to run the tuner. - /// - /// - Returns: `AespaSession`, for chaining calls. - @discardableResult - public func setStabilizationWithError(mode: AVCaptureVideoStabilizationMode) throws -> AespaSession { - let tuner = VideoStabilizationTuner(stabilzationMode: mode) - try coreSession.run(tuner) - return self +extension AespaSession: PhotoContext { + public var underlyingPhotoContext: AespaPhotoContext { + photoContext + } + + public var photoFilePublisher: AnyPublisher, Never> { + photoContext.photoFilePublisher + } + + public var currentSetting: AVCapturePhotoSettings { + photoContext.currentSetting } - /// Sets the autofocusing mode for the video recording session. - /// - /// - Parameter mode: The focus mode(`AVCaptureDevice.FocusMode`) for the session. - /// - /// - Throws: `AespaError` if the session fails to run the tuner. - /// - /// - Returns: `AespaSession`, for chaining calls. - @discardableResult - public func setAutofocusingWithError(mode: AVCaptureDevice.FocusMode) throws -> AespaSession { - let tuner = AutoFocusTuner(mode: mode) - try coreSession.run(tuner) - return self + public func capturePhotoWithError() async throws -> PhotoFile { + try await photoContext.capturePhotoWithError() } - /// Sets the zoom factor for the video recording session. - /// - /// - Parameter factor: A `CGFloat` value indicating the zoom factor to be set. - /// - /// - Throws: `AespaError` if the session fails to run the tuner. - /// - /// - Returns: `AespaSession`, for chaining calls. @discardableResult - public func zoomWithError(factor: CGFloat) throws -> AespaSession { - let tuner = ZoomTuner(zoomFactor: factor) - try coreSession.run(tuner) - return self + public func setFlashMode(to mode: AVCaptureDevice.FlashMode) -> AespaPhotoContext { + photoContext.setFlashMode(to: mode) } - /// Sets the torch mode and level for the video recording session. - /// - /// - Parameters: - /// - mode: The desired torch mode (AVCaptureDevice.TorchMode). - /// - level: The desired torch level as a Float between 0.0 and 1.0. - /// - /// - Returns: Returns self, allowing additional settings to be configured. - /// - /// - Throws: Throws an error if setting the torch mode or level fails. - /// - /// - Note: This function might throw an error if the torch mode is not supported, - /// or the specified level is not within the acceptable range. @discardableResult - public func setTorchWitherror(mode: AVCaptureDevice.TorchMode, level: Float) throws -> AespaSession { - let tuner = TorchTuner(level: level, torchMode: mode) - try coreSession.run(tuner) - return self + public func redEyeReduction(enabled: Bool) -> AespaPhotoContext { + photoContext.redEyeReduction(enabled: enabled) } - // MARK: - Customizable - /// This function provides a way to use a custom tuner to modify the current session. - /// The tuner must conform to `AespaSessionTuning`. - /// - /// - Parameter tuner: An instance that conforms to `AespaSessionTuning`. - /// - Throws: If the session fails to run the tuner. - public func customize(_ tuner: T) throws { - try coreSession.run(tuner) + public func custom(_ setting: AVCapturePhotoSettings) { + photoSetting = setting } - - // MARK: - Utilities - /// Fetches a list of recorded video files. - /// The number of files fetched is controlled by the limit parameter. - /// - /// It is recommended not to be called in main thread. - /// - /// - Parameter limit: An integer specifying the maximum number of video files to fetch. - /// If the limit is set to 0 (default), all recorded video files will be fetched. - /// - Returns: An array of `VideoFile` instances. - public func fetchVideoFiles(limit: Int = 0) -> [VideoFile] { - return fileManager.fetch(albumName: option.asset.albumName, count: limit) + + public func fetchPhotoFiles(limit: Int) -> [PhotoFile] { + photoContext.fetchPhotoFiles(limit: limit) } - - /// Checks if essential conditions to start recording are satisfied. - /// This includes checking for capture authorization, if the session is running, - /// if there is an existing connection and if a device is attached. - /// - /// - Throws: `AespaError.permission` if capture authorization is denied. - /// - Throws: `AespaError.session` if the session is not running, - /// cannot find a connection, or cannot find a device. - public func doctor() async throws { - // Check authorization status - guard - case .permitted = await AuthorizationChecker.checkCaptureAuthorizationStatus() - else { - throw AespaError.permission(reason: .denied) - } - - guard coreSession.isRunning else { - throw AespaError.session(reason: .notRunning) - } - - // Check if connection exists - guard coreSession.movieFileOutput != nil else { - throw AespaError.session(reason: .cannotFindConnection) - } - - // Check if device is attached - guard coreSession.videoDeviceInput != nil else { - throw AespaError.session(reason: .cannotFindDevice) - } + + public func custom(_ setting: AVCapturePhotoSettings) -> AespaPhotoContext { + photoContext.custom(setting) } - } extension AespaSession { diff --git a/Sources/Aespa/Context/AespaPhotoContext.swift b/Sources/Aespa/Context/AespaPhotoContext.swift new file mode 100644 index 0000000..590058d --- /dev/null +++ b/Sources/Aespa/Context/AespaPhotoContext.swift @@ -0,0 +1,120 @@ +// +// AespaPhotoContext.swift +// +// +// Created by 이영빈 on 2023/06/22. +// + +import Combine +import Foundation +import AVFoundation + +/// `AespaPhotoContext` is an open class that provides a context for photo capturing operations. +/// It has methods and properties to handle the photo capturing and settings. +open class AespaPhotoContext { + private let coreSession: AespaCoreSession + private let albumManager: AespaCoreAlbumManager + private let fileManager: AespaCoreFileManager + private let option: AespaOption + + private let camera: AespaCoreCamera + + private var photoSetting: AVCapturePhotoSettings + private let photoFileBufferSubject: CurrentValueSubject?, Never> + + init( + coreSession: AespaCoreSession, + camera: AespaCoreCamera, + albumManager: AespaCoreAlbumManager, + fileManager: AespaCoreFileManager, + option: AespaOption + ) { + self.coreSession = coreSession + self.camera = camera + self.albumManager = albumManager + self.fileManager = fileManager + self.option = option + + self.photoSetting = AVCapturePhotoSettings() + self.photoFileBufferSubject = .init(nil) + + // Add first video file to buffer if it exists + if let firstPhotoFile = fileManager.fetchPhoto( + albumName: option.asset.albumName, + subDirectoryName: option.asset.photoDirectoryName, + count: 1).first + { + photoFileBufferSubject.send(.success(firstPhotoFile)) + } + } +} + +extension AespaPhotoContext: PhotoContext { + public var underlyingPhotoContext: AespaPhotoContext { + self + } + + public var photoFilePublisher: AnyPublisher, Never> { + photoFileBufferSubject.handleEvents(receiveOutput: { status in + if case .failure(let error) = status { + Logger.log(error: error) + } + }) + .compactMap({ $0 }) + .eraseToAnyPublisher() + } + + public var currentSetting: AVCapturePhotoSettings { + photoSetting + } + + public func capturePhotoWithError() async throws -> PhotoFile { + let setting = AVCapturePhotoSettings(from: photoSetting) + let rawPhotoAsset = try await camera.capture(setting: setting) + + guard let rawPhotoData = rawPhotoAsset.fileDataRepresentation() else { + throw AespaError.file(reason: .unableToFlatten) + } + + let filePath = try FilePathProvider.requestFilePath( + from: fileManager.systemFileManager, + directoryName: option.asset.albumName, + subDirectoryName: option.asset.photoDirectoryName, + fileName: option.asset.fileNameHandler()) + + try fileManager.write(data: rawPhotoData, to: filePath) + try await albumManager.addToAlbum(imageData: rawPhotoData) + + let photoFile = PhotoFileGenerator.generate( + with: filePath, + date: Date()) + + photoFileBufferSubject.send(.success(photoFile)) + + return photoFile + } + + @discardableResult + public func setFlashMode(to mode: AVCaptureDevice.FlashMode) -> AespaPhotoContext { + photoSetting.flashMode = mode + return self + } + + @discardableResult + public func redEyeReduction(enabled: Bool) -> AespaPhotoContext { + photoSetting.isAutoRedEyeReductionEnabled = enabled + return self + } + + public func custom(_ setting: AVCapturePhotoSettings) -> AespaPhotoContext { + photoSetting = setting + return self + } + + public func fetchPhotoFiles(limit: Int) -> [PhotoFile] { + return fileManager.fetchPhoto( + albumName: option.asset.albumName, + subDirectoryName: option.asset.photoDirectoryName, + count: limit) + } +} diff --git a/Sources/Aespa/Context/AespaVideoContext.swift b/Sources/Aespa/Context/AespaVideoContext.swift new file mode 100644 index 0000000..dfdab58 --- /dev/null +++ b/Sources/Aespa/Context/AespaVideoContext.swift @@ -0,0 +1,142 @@ +// +// AespaVideoContext.swift +// +// +// Created by 이영빈 on 2023/06/22. +// + +import UIKit + +import Combine +import Foundation +import AVFoundation + +/// `AespaVideoContext` is an open class that provides a context for video recording operations. +/// It has methods and properties to handle the video recording and settings. +public class AespaVideoContext { + private let commonContext: Common + private let coreSession: AespaCoreSession + private let albumManager: AespaCoreAlbumManager + private let fileManager: AespaCoreFileManager + private let option: AespaOption + + private let recorder: AespaCoreRecorder + + private let videoFileBufferSubject: CurrentValueSubject?, Never> + + public var isRecording: Bool + + init( + commonContext: Common, + coreSession: AespaCoreSession, + recorder: AespaCoreRecorder, + albumManager: AespaCoreAlbumManager, + fileManager: AespaCoreFileManager, + option: AespaOption + ) { + self.commonContext = commonContext + self.coreSession = coreSession + self.recorder = recorder + self.albumManager = albumManager + self.fileManager = fileManager + self.option = option + + self.videoFileBufferSubject = .init(nil) + + self.isRecording = false + + // Add first video file to buffer if it exists + if let firstVideoFile = fileManager.fetchVideo( + albumName: option.asset.albumName, + subDirectoryName: option.asset.videoDirectoryName, + count: 1).first + { + videoFileBufferSubject.send(.success(firstVideoFile)) + } + } +} + +extension AespaVideoContext: VideoContext { + public var underlyingVideoContext: AespaVideoContext { + self + } + + public var isMuted: Bool { + coreSession.audioDeviceInput == nil + } + public var videoFilePublisher: AnyPublisher, Never> { + videoFileBufferSubject.handleEvents(receiveOutput: { status in + if case .failure(let error) = status { + Logger.log(error: error) + } + }) + .compactMap({ $0 }) + .eraseToAnyPublisher() + } + + public func startRecordingWithError() throws { + let fileName = option.asset.fileNameHandler() + let filePath = try FilePathProvider.requestFilePath( + from: fileManager.systemFileManager, + directoryName: option.asset.albumName, + subDirectoryName: option.asset.videoDirectoryName, + fileName: fileName, + extension: "mp4") + + if option.session.autoVideoOrientationEnabled { + try commonContext.setOrientationWithError(to: UIDevice.current.orientation.toVideoOrientation) + } + + try recorder.startRecording(in: filePath) + } + + public func stopRecordingWithError() async throws -> VideoFile { + let videoFilePath = try await recorder.stopRecording() + let videoFile = VideoFileGenerator.generate(with: videoFilePath, date: Date()) + + try await albumManager.addToAlbum(filePath: videoFilePath) + videoFileBufferSubject.send(.success(videoFile)) + + return videoFile + } + + @discardableResult + public func muteWithError() throws -> AespaVideoContext { + let tuner = AudioTuner(isMuted: true) + try coreSession.run(tuner) + return self + } + + @discardableResult + public func unmuteWithError() throws -> AespaVideoContext { + let tuner = AudioTuner(isMuted: false) + try coreSession.run(tuner) + return self + } + + @discardableResult + public func setStabilizationWithError(mode: AVCaptureVideoStabilizationMode) throws -> AespaVideoContext { + let tuner = VideoStabilizationTuner(stabilzationMode: mode) + try coreSession.run(tuner) + return self + } + + @discardableResult + public func setTorchWithError(mode: AVCaptureDevice.TorchMode, level: Float) throws -> AespaVideoContext { + let tuner = TorchTuner(level: level, torchMode: mode) + try coreSession.run(tuner) + return self + } + + public func customizewWithError(_ tuner: T) throws -> AespaVideoContext { + try coreSession.run(tuner) + return self + } + + public func fetchVideoFiles(limit: Int) -> [VideoFile] { + return fileManager.fetchVideo( + albumName: option.asset.albumName, + subDirectoryName: option.asset.videoDirectoryName, + count: limit) + } +} diff --git a/Sources/Aespa/Context/Context.swift b/Sources/Aespa/Context/Context.swift new file mode 100644 index 0000000..399cf00 --- /dev/null +++ b/Sources/Aespa/Context/Context.swift @@ -0,0 +1,547 @@ +// +// File.swift +// +// +// Created by 이영빈 on 2023/06/24. +// + +import UIKit +import Combine +import Foundation +import AVFoundation + +public typealias ErrorHandler = (Error) -> Void + +public protocol CommonContext { + associatedtype CommonContextType: CommonContext & VideoContext & PhotoContext + + var underlyingCommonContext: CommonContextType { get } + + /// Sets the quality preset for the video recording session. + /// + /// - Parameter preset: An `AVCaptureSession.Preset` value indicating the quality preset to be set. + /// + /// - Throws: `AespaError` if the session fails to run the tuner. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult func setQualityWithError(to preset: AVCaptureSession.Preset) throws -> CommonContextType + + /// Sets the camera position for the video recording session. + /// + /// It refers to `AespaOption.Session.cameraDevicePreference` when choosing the camera device. + /// + /// - Parameter position: An `AVCaptureDevice.Position` value indicating the camera position to be set. + /// + /// - Throws: `AespaError` if the session fails to run the tuner. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult func setPositionWithError(to position: AVCaptureDevice.Position) throws -> CommonContextType + + /// Sets the orientation for the session. + /// + /// - Parameter orientation: An `AVCaptureVideoOrientation` value indicating the orientation to be set. + /// + /// - Throws: `AespaError` if the session fails to run the tuner. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + /// + /// - Note: It sets the orientation of the video you are recording, + /// not the orientation of the `AVCaptureVideoPreviewLayer`. + @discardableResult func setOrientationWithError(to orientation: AVCaptureVideoOrientation) throws -> CommonContextType + + /// Sets the autofocusing mode for the video recording session. + /// + /// - Parameter mode: The focus mode(`AVCaptureDevice.FocusMode`) for the session. + /// + /// - Throws: `AespaError` if the session fails to run the tuner. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult func setAutofocusingWithError(mode: AVCaptureDevice.FocusMode) throws -> CommonContextType + + /// Sets the zoom factor for the video recording session. + /// + /// - Parameter factor: A `CGFloat` value indicating the zoom factor to be set. + /// + /// - Throws: `AespaError` if the session fails to run the tuner. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult func zoomWithError(factor: CGFloat) throws -> CommonContextType + + /// This function provides a way to use a custom tuner to modify the current session. + /// The tuner must conform to `AespaSessionTuning`. + /// + /// - Parameter tuner: An instance that conforms to `AespaSessionTuning`. + /// - Throws: If the session fails to run the tuner. + @discardableResult func customizeWithError(_ tuner: T) throws -> CommonContextType +} + +// MARK: Non-throwing methods +// These methods encapsulate error handling within the method itself rather than propagating it to the caller. +// This means any errors that occur during the execution of these methods will be caught and logged, not thrown. +// Although it simplifies error handling, this approach may not be recommended because it offers less control to callers. +// Developers are encouraged to use methods that throw errors, to gain finer control over error handling. +extension CommonContext { + /// Sets the quality preset for the video recording session. + /// + /// - Parameter preset: An `AVCaptureSession.Preset` value indicating the quality preset to be set. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult + public func setQuality( + to preset: AVCaptureSession.Preset, + errorHandler: ErrorHandler? = nil + ) -> CommonContextType { + do { + return try self.setQualityWithError(to: preset) + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + + return underlyingCommonContext + } + + /// Sets the camera position for the video recording session. + /// + /// - Parameter position: An `AVCaptureDevice.Position` value indicating the camera position to be set. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult + public func setPosition( + to position: AVCaptureDevice.Position, + errorHandler: ErrorHandler? = nil + ) -> CommonContextType { + do { + return try self.setPositionWithError(to: position) + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + + return underlyingCommonContext + } + + /// Sets the orientation for the session. + /// + /// - Parameter orientation: An `AVCaptureVideoOrientation` value indicating the orientation to be set. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Note: It sets the orientation of the video you are recording, + /// not the orientation of the `AVCaptureVideoPreviewLayer`. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult + public func setOrientation( + to orientation: AVCaptureVideoOrientation, + errorHandler: ErrorHandler? = nil + ) -> CommonContextType { + do { + return try self.setOrientationWithError(to: orientation) + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + + return underlyingCommonContext + } + + /// Sets the autofocusing mode for the video recording session. + /// + /// - Parameter mode: The focus mode for the capture device. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult + public func setAutofocusing( + mode: AVCaptureDevice.FocusMode, + errorHandler: ErrorHandler? = nil + ) -> CommonContextType { + do { + return try self.setAutofocusingWithError(mode: mode) + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + + return underlyingCommonContext + } + + /// Sets the zoom factor for the video recording session. + /// + /// - Parameter factor: A `CGFloat` value indicating the zoom factor to be set. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult + public func zoom( + factor: CGFloat, + errorHandler: ErrorHandler? = nil + ) -> CommonContextType { + do { + return try self.zoomWithError(factor: factor) + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + + return underlyingCommonContext + } + + @discardableResult + public func custom( + _ tuner: T, + errorHandler: ErrorHandler? = nil + ) -> CommonContextType { + do { + return try self.customizeWithError(tuner) + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + + return underlyingCommonContext + } +} + + +public protocol VideoContext { + associatedtype VideoContextType: VideoContext + + var underlyingVideoContext: VideoContextType { get } + + var isRecording: Bool { get } + + /// This publisher is responsible for emitting `VideoFile` objects resulting from completed recordings. + /// + /// In the case of an error, it logs the error before forwarding it wrapped in a `Result.failure`. + /// If you don't want to show logs, set `enableLogging` to `false` from `AespaOption.Log` + /// + /// - Returns: `VideoFile` wrapped in a `Result` type. + var videoFilePublisher: AnyPublisher, Never> { get } + + /// This property reflects the current state of audio input. + /// + /// If it returns `true`, the audio input is currently muted. + var isMuted: Bool { get } + + /// - Throws: `AespaError` if the video file path request fails, + /// orientation setting fails, or starting the recording fails. + /// + /// - Note: If `autoVideoOrientation` option is enabled, + /// it sets the orientation according to the current device orientation. + func startRecordingWithError() throws + + /// Stops the ongoing video recording session and attempts to add the video file to the album. + /// + /// Supporting `async`, you can use this method in Swift Concurrency's context + /// + /// - Throws: `AespaError` if stopping the recording fails. + @discardableResult func stopRecordingWithError() async throws -> VideoFile + + /// Mutes the audio input for the video recording session. + /// + /// - Throws: `AespaError` if the session fails to run the tuner. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult func muteWithError() throws -> VideoContextType + + /// Unmutes the audio input for the video recording session. + /// + /// - Throws: `AespaError` if the session fails to run the tuner. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult func unmuteWithError() throws -> VideoContextType + + /// Sets the stabilization mode for the video recording session. + /// + /// - Parameter mode: An `AVCaptureVideoStabilizationMode` value + /// indicating the stabilization mode to be set. + /// + /// - Throws: `AespaError` if the session fails to run the tuner. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult func setStabilizationWithError(mode: AVCaptureVideoStabilizationMode) throws -> VideoContextType + + /// Sets the torch mode and level for the video recording session. + /// + /// - Parameters: + /// - mode: The desired torch mode (AVCaptureDevice.TorchMode). + /// - level: The desired torch level as a Float between 0.0 and 1.0. + /// + /// - Returns: Returns self, allowing additional settings to be configured. + /// + /// - Throws: Throws an error if setting the torch mode or level fails. + /// + /// - Note: This function might throw an error if the torch mode is not supported, + /// or the specified level is not within the acceptable range. + @discardableResult func setTorchWithError(mode: AVCaptureDevice.TorchMode, level: Float) throws -> VideoContextType + + /// Fetches a list of recorded video files. + /// The number of files fetched is controlled by the limit parameter. + /// + /// It is recommended not to be called in main thread. + /// + /// - Parameter limit: An integer specifying the maximum number of video files to fetch. + /// + /// - Returns: An array of `VideoFile` instances. + func fetchVideoFiles(limit: Int) -> [VideoFile] +} + +// MARK: Non-throwing methods +// These methods encapsulate error handling within the method itself rather than propagating it to the caller. +// This means any errors that occur during the execution of these methods will be caught and logged, not thrown. +// Although it simplifies error handling, this approach may not be recommended because it offers less control to callers. +// Developers are encouraged to use methods that throw errors, to gain finer control over error handling. +extension VideoContext { + /// Starts the recording of a video session. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Note: If auto video orientation is enabled, + /// it sets the orientation according to the current device orientation. + public func startRecording(errorHandler: ErrorHandler? = nil) { + do { + try startRecordingWithError() + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + } + + /// Stops the current video recording session and attempts to save the video file to the album. + /// + /// Any errors that occur during the process are captured and logged. + /// + /// - Parameter completionHandler: A closure that handles the result of the operation. + /// It's called with a `Result` object that encapsulates either a `VideoFile` instance. + /// + /// - Note: It is recommended to use the ``stopRecording() async throws`` + /// for more straightforward error handling. + public func stopRecording( + _ completionHandler: @escaping (Result) -> Void = { _ in } + ) { + Task(priority: .utility) { + do { + let videoFile = try await self.stopRecordingWithError() + return completionHandler(.success(videoFile)) + } catch let error { + Logger.log(error: error) + return completionHandler(.failure(error)) + } + } + } + + /// Mutes the audio input for the video recording session. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult + public func mute(errorHandler: ErrorHandler? = nil) -> VideoContextType { + do { + return try self.muteWithError() + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + + return underlyingVideoContext + } + + /// Unmutes the audio input for the video recording session. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult + public func unmute(errorHandler: ErrorHandler? = nil) -> VideoContextType { + do { + return try self.unmuteWithError() + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + + return underlyingVideoContext + } + } + + /// Sets the stabilization mode for the video recording session. + /// + /// - Parameter mode: An `AVCaptureVideoStabilizationMode` value + /// indicating the stabilization mode to be set. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Returns: `AespaVideoContext`, for chaining calls. + @discardableResult + public func setStabilization( + mode: AVCaptureVideoStabilizationMode, + errorHandler: ErrorHandler? = nil + ) -> VideoContextType { + do { + return try self.setStabilizationWithError(mode: mode) + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + + return underlyingVideoContext + } + + + /// Sets the torch mode and level for the video recording session. + /// + /// If an error occurs during the operation, the error is logged. + /// + /// - Parameters: + /// - mode: The desired torch mode (AVCaptureDevice.TorchMode). + /// - level: The desired torch level as a Float between 0.0 and 1.0. + /// + /// - Returns: Returns self, allowing additional settings to be configured. + /// + /// - Note: This function might throw an error if the torch mode is not supported, + /// or the specified level is not within the acceptable range. + @discardableResult + public func setTorch( + mode: AVCaptureDevice.TorchMode, + level: Float, + errorHandler: ErrorHandler? = nil + ) -> VideoContextType { + do { + return try self.setTorchWithError(mode: mode, level: level) + } catch let error { + errorHandler?(error) + Logger.log(error: error) // Logs any errors encountered during the operation + } + + return underlyingVideoContext + } + + /// Fetches a list of recorded video files. + /// The number of files fetched is controlled by the limit parameter. + /// + /// It is recommended not to be called in main thread. + /// + /// - Parameter limit: An integer specifying the maximum number of video files to fetch. + /// If the limit is set to 0 (default), all recorded video files will be fetched. + /// - Returns: An array of `VideoFile` instances. + public func fetchVideoFiles(limit: Int = 0) -> [VideoFile] { + fetchVideoFiles(limit: limit) + } +} + + +public protocol PhotoContext { + associatedtype PhotoContextType: PhotoContext + + var underlyingPhotoContext: PhotoContextType { get } + + /// The publisher that broadcasts the result of a photo file operation. + /// It emits a `Result` object containing a `PhotoFile` on success or an `Error` on failure, + /// and never fails itself. This can be used to observe the photo capturing process and handle + /// the results asynchronously. + var photoFilePublisher: AnyPublisher, Never> { get } + + /// A variable holding current `AVCapturePhotoSettings` + var currentSetting: AVCapturePhotoSettings { get } + + /// Asynchronously captures a photo with the specified `AVCapturePhotoSettings`. + /// + /// The captured photo is flattened into a `Data` object, and then added to an album. A `PhotoFile` + /// object is then created using the raw photo data and the current date. This `PhotoFile` is sent + /// through the `photoFileBufferSubject` and then returned to the caller. + /// + /// If any part of this process fails, an `AespaError` is thrown. + /// + /// - Returns: A `PhotoFile` object representing the captured photo. + /// - Throws: An `AespaError` if there is an issue capturing the photo, + /// flattening it into a `Data` object, or adding it to the album. + @discardableResult func capturePhotoWithError() async throws -> PhotoFile + + /// Sets the flash mode for the camera and returns the updated `AespaPhotoContext` instance. + /// The returned instance can be used for chaining configuration. + /// + /// - Parameter mode: The `AVCaptureDevice.FlashMode` to set for the camera. + /// - Returns: The updated `AespaPhotoContext` instance. + @discardableResult func setFlashMode(to mode: AVCaptureDevice.FlashMode) -> PhotoContextType + + /// Sets the red eye reduction mode for the camera and returns the updated `AespaPhotoContext` instance. + /// The returned instance can be used for chaining configuration. + /// + /// - Parameter enabled: A boolean indicating whether the red eye reduction should be enabled or not. + /// - Returns: The updated `AespaPhotoContext` instance. + @discardableResult func redEyeReduction(enabled: Bool) -> PhotoContextType + + /// Updates the photo capturing settings for the `AespaPhotoContext` instance. + /// + /// - Note: This method can be potentially risky to use, as it overrides the existing capture settings. + /// Not all `AVCapturePhotoSettings` are supported, for instance, live photos are not supported. + /// It's recommended to understand the implications of the settings before applying them. + /// + /// - Parameter setting: The `AVCapturePhotoSettings` to use for photo capturing. + func custom(_ setting: AVCapturePhotoSettings) -> PhotoContextType + + // MARK: - Utilities + /// Fetches a list of captured photo files. + /// The number of files fetched is controlled by the limit parameter. + /// + /// It is recommended not to be called in main thread. + /// + /// - Parameter limit: An integer specifying the maximum number of files to fetch. + /// + /// - Returns: An array of `PhotoFile` instances. + func fetchPhotoFiles(limit: Int) -> [PhotoFile] +} + +// MARK: Non-throwing methods +// These methods encapsulate error handling within the method itself rather than propagating it to the caller. +// This means any errors that occur during the execution of these methods will be caught and logged, not thrown. +// Although it simplifies error handling, this approach may not be recommended because it offers less control to callers. +// Developers are encouraged to use methods that throw errors, to gain finer control over error handling. +extension PhotoContext { + /// Asynchronously captures a photo using the specified `AVCapturePhotoSettings`. + /// + /// If the photo capture is successful, it will return a `PhotoFile` + /// object through the provided completion handler. + /// + /// In case of an error during the photo capture process, the error will be logged and also returned via + /// the completion handler. + /// + /// - Parameters: + /// - completionHandler: A closure to be invoked once the photo capture process is completed. This + /// closure takes a `Result` type where `Success` contains a `PhotoFile` object and + /// `Failure` contains an `Error` object. By default, the closure does nothing. + /// + public func capturePhoto( + _ completionHandler: @escaping (Result) -> Void = { _ in } + ) { + Task(priority: .utility) { + do { + let photoFile = try await self.capturePhotoWithError() + return completionHandler(.success(photoFile)) + } catch let error { + Logger.log(error: error) + return completionHandler(.failure(error)) + } + } + } + + /// Fetches a list of captured photo files. + /// The number of files fetched is controlled by the limit parameter. + /// + /// It is recommended not to be called in main thread. + /// + /// - Parameter limit: An integer specifying the maximum number of files to fetch. + /// If the limit is set to 0 (default), all recorded video files will be fetched. + /// - Returns: An array of `PhotoFile` instances. + public func fetchPhotoFiles(limit: Int = 0) -> [PhotoFile] { + fetchPhotoFiles(limit: limit) + } +} diff --git a/Sources/Aespa/Core/AespaCoreAlbumManager.swift b/Sources/Aespa/Core/AespaCoreAlbumManager.swift index e8b964e..4288e55 100644 --- a/Sources/Aespa/Core/AespaCoreAlbumManager.swift +++ b/Sources/Aespa/Core/AespaCoreAlbumManager.swift @@ -6,7 +6,6 @@ // import Photos -import AVFoundation /// Retreive the video(url) from `FileManager` based local storage /// and add the video to the pre-defined album roll diff --git a/Sources/Aespa/Core/AespaCoreFileManager.swift b/Sources/Aespa/Core/AespaCoreFileManager.swift index 13886bc..5678198 100644 --- a/Sources/Aespa/Core/AespaCoreFileManager.swift +++ b/Sources/Aespa/Core/AespaCoreFileManager.swift @@ -5,10 +5,12 @@ // Created by 이영빈 on 2023/06/13. // +import Photos import Foundation class AespaCoreFileManager { - private var videoFileProxyDictionary: [String: VideoFileCachingProxy>] + private var videoFileProxyDictionary: [String: FileCachingProxy] + private var photoFileProxyDictionary: [String: FileCachingProxy] private let enableCaching: Bool let systemFileManager: FileManager @@ -17,29 +19,96 @@ class AespaCoreFileManager { enableCaching: Bool, fileManager: FileManager = .default ) { - videoFileProxyDictionary = [:] + self.videoFileProxyDictionary = [:] + self.photoFileProxyDictionary = [:] + self.enableCaching = enableCaching self.systemFileManager = fileManager } + + func write(data: Data, to path: URL) throws { + // Check if the directory exists, if not, returns. + guard !systemFileManager.fileExists(atPath: path.deletingLastPathComponent().absoluteString) else { + return + } + + try data.write(to: path) + } + + func fetchAllMediaType(albumName: String, count: Int) -> [PhotoFile] { + guard count >= 0 else { return [] } + + guard + let albumDirectory = try? FilePathProvider.requestDirectoryPath(from: systemFileManager, + directoryName: albumName) + else { + Logger.log(message: "Cannot fetch album directory so `fetch` will return empty array.") + return [] + } + guard let proxy = photoFileProxyDictionary[albumName] else { + photoFileProxyDictionary[albumName] = FileCachingProxy( + fileDirectory: albumDirectory, + enableCaching: enableCaching, + fileManager: systemFileManager, + fileFactory: photoFileFactory) + + return fetchAllMediaType(albumName: albumName, count: count) + } + + let files = proxy.fetch(count: count) + Logger.log(message: "\(files.count) Photo files fetched") + return files + } + + func fetchPhoto(albumName: String, subDirectoryName: String, count: Int) -> [PhotoFile] { + guard count >= 0 else { return [] } + + guard + let albumDirectory = try? FilePathProvider.requestDirectoryPath(from: systemFileManager, + directoryName: albumName, + subDirectoryName: subDirectoryName) + else { + Logger.log(message: "Cannot fetch album directory so `fetch` will return empty array.") + return [] + } + + guard let proxy = photoFileProxyDictionary[albumName] else { + photoFileProxyDictionary[albumName] = FileCachingProxy( + fileDirectory: albumDirectory, + enableCaching: enableCaching, + fileManager: systemFileManager, + fileFactory: photoFileFactory) + + return fetchPhoto(albumName: albumName, subDirectoryName: subDirectoryName, count: count) + } + + let files = proxy.fetch(count: count) + Logger.log(message: "\(files.count) Photo files fetched") + return files + } + /// If `count` is `0`, return all existing files - func fetch(albumName: String, count: Int) -> [VideoFile] { + func fetchVideo(albumName: String, subDirectoryName: String, count: Int) -> [VideoFile] { guard count >= 0 else { return [] } - guard let albumDirectory = try? VideoFilePathProvider.requestDirectoryPath(from: systemFileManager, - name: albumName) + guard + let albumDirectory = try? FilePathProvider.requestDirectoryPath(from: systemFileManager, + directoryName: albumName, + subDirectoryName: subDirectoryName) else { Logger.log(message: "Cannot fetch album directory so `fetch` will return empty array.") return [] } guard let proxy = videoFileProxyDictionary[albumName] else { - videoFileProxyDictionary[albumName] = VideoFileCachingProxy( - albumDirectory: albumDirectory, + videoFileProxyDictionary[albumName] = FileCachingProxy( + fileDirectory: albumDirectory, enableCaching: enableCaching, - fileManager: systemFileManager) + fileManager: systemFileManager, + fileFactory: videoFileFactory) - return fetch(albumName: albumName, count: count) + return fetchVideo(albumName: albumName, subDirectoryName: subDirectoryName, count: count) } let files = proxy.fetch(count: count) @@ -47,3 +116,29 @@ class AespaCoreFileManager { return files } } + +private extension AespaCoreFileManager { + func videoFileFactory(_ fileManager: FileManager, _ filePath: URL) -> VideoFile? { + guard + let fileAttributes = try? fileManager.attributesOfItem(atPath: filePath.path), + let creationDate = fileAttributes[.creationDate] as? Date + else { + Logger.log(message: "Cannot access to saved video file") + return nil + } + + return VideoFileGenerator.generate(with: filePath, date: creationDate) + } + + func photoFileFactory(_ fileManager: FileManager, _ filePath: URL) -> PhotoFile? { + guard + let fileAttributes = try? fileManager.attributesOfItem(atPath: filePath.path), + let creationDate = fileAttributes[.creationDate] as? Date + else { + Logger.log(message: "Cannot access to saved photo file") + return nil + } + + return PhotoFileGenerator.generate(with: filePath, date: creationDate) + } +} diff --git a/Sources/Aespa/Core/Representable/Photos+AespaRepresentable.swift b/Sources/Aespa/Core/Representable/Photos+AespaRepresentable.swift index ae586b9..85bb7a5 100644 --- a/Sources/Aespa/Core/Representable/Photos+AespaRepresentable.swift +++ b/Sources/Aespa/Core/Representable/Photos+AespaRepresentable.swift @@ -12,10 +12,10 @@ protocol AespaAssetLibraryRepresentable { func performChanges(_ changes: @escaping () -> Void) async throws func performChangesAndWait(_ changeBlock: @escaping () -> Void) throws func requestAuthorization(for accessLevel: PHAccessLevel) async -> PHAuthorizationStatus + func fetchAlbum( title: String, - fetchOptions: - PHFetchOptions + fetchOptions: PHFetchOptions ) -> Collection? } @@ -39,7 +39,7 @@ extension PHPhotoLibrary: AespaAssetLibraryRepresentable { return collections.firstObject as? Collection } - + func requestAuthorization(for accessLevel: PHAccessLevel) async -> PHAuthorizationStatus { await PHPhotoLibrary.requestAuthorization(for: accessLevel) } diff --git a/Sources/Aespa/Data/File/PhotoFile.swift b/Sources/Aespa/Data/File/PhotoFile.swift new file mode 100644 index 0000000..00f656a --- /dev/null +++ b/Sources/Aespa/Data/File/PhotoFile.swift @@ -0,0 +1,58 @@ +// +// PhotoFile.swift +// +// +// Created by 이영빈 on 2023/06/18. +// + +import UIKit +import SwiftUI +import Foundation + +/// `PhotoFile` represents a photo file with its associated metadata. +/// +/// This struct holds information about the video file, including the path to the video file (`path`), +/// and an optional thumbnail image (`thumbnail`) +/// generated from the photo. +public struct PhotoFile { + /// A `Date` value keeps the date it's generated + public let generatedDate: Date + + /// The path to the photo file data. It's saved in the form of `Data. + /// If you want to load it directly you should encode it to any image type. + public let path: URL + + /// An optional thumbnail generated from the video with `UIImage` type. + /// This will be `nil` if the thumbnail could not be generated for some reason. + public var thumbnail: UIImage? +} + +extension PhotoFile: Identifiable { + public var id: URL { + self.path + } +} + +extension PhotoFile: Equatable { + public static func == (lhs: PhotoFile, rhs: PhotoFile) -> Bool { + lhs.path == rhs.path + } +} + +extension PhotoFile: Comparable { + public static func < (lhs: PhotoFile, rhs: PhotoFile) -> Bool { + lhs.generatedDate > rhs.generatedDate + } +} + +public extension PhotoFile { + /// An optional thumbnail generated from the video with SwiftUI `Image` type. + /// This will be `nil` if the thumbnail could not be generated for some reason. + var thumbnailImage: Image? { + if let thumbnail { + return Image(uiImage: thumbnail) + } + + return nil + } +} diff --git a/Sources/Aespa/Data/VideoFile.swift b/Sources/Aespa/Data/File/VideoFile.swift similarity index 85% rename from Sources/Aespa/Data/VideoFile.swift rename to Sources/Aespa/Data/File/VideoFile.swift index b440b57..643b17e 100644 --- a/Sources/Aespa/Data/VideoFile.swift +++ b/Sources/Aespa/Data/File/VideoFile.swift @@ -11,8 +11,8 @@ import AVFoundation /// `VideoFile` represents a video file with its associated metadata. /// -/// This struct holds information about the video file, including a unique identifier (`id`), -/// the path to the video file (`path`), and an optional thumbnail image (`thumbnail`) +/// This struct holds information about the video file, including the path to the video file (`path`), +/// and an optional thumbnail image (`thumbnail`) /// generated from the video. public struct VideoFile { /// A `Date` value keeps the date it's generated @@ -26,33 +26,32 @@ public struct VideoFile { public var thumbnail: UIImage? } -/// UI related extension methods -public extension VideoFile { - /// An optional thumbnail generated from the video with SwiftUI `Image` type. - /// This will be `nil` if the thumbnail could not be generated for some reason. - var thumbnailImage: Image? { - if let thumbnail { - return Image(uiImage: thumbnail) - } - - return nil +extension VideoFile: Identifiable { + public var id: URL { + self.path } } extension VideoFile: Equatable { - public static func == (lhs: VideoFile, rhs: VideoFile ) -> Bool { + public static func == (lhs: VideoFile, rhs: VideoFile) -> Bool { lhs.path == rhs.path } } -extension VideoFile: Identifiable { - public var id: URL { - self.path - } -} - extension VideoFile: Comparable { public static func < (lhs: VideoFile, rhs: VideoFile) -> Bool { lhs.generatedDate > rhs.generatedDate } } + +public extension VideoFile { + /// An optional thumbnail generated from the video with SwiftUI `Image` type. + /// This will be `nil` if the thumbnail could not be generated for some reason. + var thumbnailImage: Image? { + if let thumbnail { + return Image(uiImage: thumbnail) + } + + return nil + } +} diff --git a/Sources/Aespa/Data/PhotoFile.swift b/Sources/Aespa/Data/PhotoFile.swift deleted file mode 100644 index f332709..0000000 --- a/Sources/Aespa/Data/PhotoFile.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// PhotoFile.swift -// -// -// Created by 이영빈 on 2023/06/18. -// - -import UIKit -import SwiftUI -import Foundation - -public struct PhotoFile: Identifiable, Equatable { - public let id = UUID() - - /// A `Data` value containing image's raw data - public let data: Data - - /// A `Date` value keeps the date it's generated - public let generatedDate: Date - - /// An optional thumbnail generated from the video with `UIImage` type. - /// This will be `nil` if the thumbnail could not be generated for some reason. - public var thumbnail: UIImage? -} - -extension PhotoFile: Comparable { - public static func < (lhs: PhotoFile, rhs: PhotoFile) -> Bool { - lhs.generatedDate > rhs.generatedDate - } -} diff --git a/Sources/Aespa/Data/Proxy/VideoFileCachingProxy.swift b/Sources/Aespa/Data/Proxy/FileCachingProxy.swift similarity index 55% rename from Sources/Aespa/Data/Proxy/VideoFileCachingProxy.swift rename to Sources/Aespa/Data/Proxy/FileCachingProxy.swift index 8550453..f61a603 100644 --- a/Sources/Aespa/Data/Proxy/VideoFileCachingProxy.swift +++ b/Sources/Aespa/Data/Proxy/FileCachingProxy.swift @@ -1,43 +1,46 @@ // -// VideoFileCachingProxy.swift -// +// ileCachingProxy.swift +// // // Created by 이영빈 on 2023/06/14. // import Foundation -class VideoFileCachingProxy> { - private let albumDirectory: URL +class FileCachingProxy { + private let fileDirectory: URL private let cacheEnabled: Bool private let fileManager: FileManager - private var cacheStroage: CacheStorage + private let fileFactory: (FileManager, URL) -> File? + + private var cacheStorage: URLCacheStorage private(set) var lastModificationDate: Date? init( - albumDirectory: URL, + fileDirectory: URL, enableCaching: Bool, fileManager: FileManager, - cacheStorage: CacheStorage = URLCacheStorage() + cacheStorage: URLCacheStorage = URLCacheStorage(), + fileFactory: @escaping (FileManager, URL) -> File? ) { - self.albumDirectory = albumDirectory - self.cacheStroage = cacheStorage - + self.fileDirectory = fileDirectory self.cacheEnabled = enableCaching self.fileManager = fileManager + self.cacheStorage = cacheStorage + self.fileFactory = fileFactory } /// If `count` is `0`, return all existing files - func fetch(count: Int = 0) -> [VideoFile] { + func fetch(count: Int = 0) -> [File] { guard cacheEnabled else { return fetchSortedFiles(count: count, usingCache: cacheEnabled) } // Get directory's last modification date guard - let directoryAttributes = try? fileManager.attributesOfItem(atPath: albumDirectory.path), + let directoryAttributes = try? fileManager.attributesOfItem(atPath: fileDirectory.path), let currentModificationDate = directoryAttributes[.modificationDate] as? Date else { return [] @@ -50,7 +53,7 @@ class VideoFileCachingProxy> { } // Update cache and lastModificationDate - update(cacheStroage) + update(cacheStorage) lastModificationDate = currentModificationDate return fetchSortedFiles(count: count, usingCache: cacheEnabled) @@ -59,68 +62,56 @@ class VideoFileCachingProxy> { // Invalidate the cache if needed, for example when a file is added or removed func invalidate() { lastModificationDate = nil - cacheStroage.empty() + cacheStorage.empty() } func renew() { - update(cacheStroage) + update(cacheStorage) } } -private extension VideoFileCachingProxy { - func update(_ storage: CacheStorage) { +private extension FileCachingProxy { + func update(_ storage: URLCacheStorage) { guard - let filePaths = try? fileManager.contentsOfDirectory(atPath: albumDirectory.path) + let filePaths = try? fileManager.contentsOfDirectory(atPath: fileDirectory.path) else { - Logger.log(message: "Cannot access to saved video file") + Logger.log(message: "Cannot access to saved file") return } filePaths.forEach { fileName in - let filePath = albumDirectory.appendingPathComponent(fileName) + let filePath = fileDirectory.appendingPathComponent(fileName) guard storage.get(filePath) == nil else { return } - guard let videoFile = createVideoFile(for: filePath) else { + guard let file = fileFactory(fileManager, filePath) else { return } - storage.store(videoFile, at: filePath) + storage.store(file, at: filePath) } } - func fetchSortedFiles(count: Int, usingCache: Bool) -> [VideoFile] { - let files: [VideoFile] + func fetchSortedFiles(count: Int, usingCache: Bool) -> [File] { + let files: [File] if usingCache { - files = cacheStroage.all.sorted() + files = cacheStorage.all.sorted() } else { - guard let filePaths = try? fileManager.contentsOfDirectory(atPath: albumDirectory.path) else { - Logger.log(message: "Cannot access to saved video file") + guard let filePaths = try? fileManager.contentsOfDirectory(atPath: fileDirectory.path) else { + Logger.log(message: "Cannot access to saved file") return [] } files = filePaths .map { fileName in - let filePath = albumDirectory.appendingPathComponent(fileName) - return createVideoFile(for: filePath) + let filePath = fileDirectory.appendingPathComponent(fileName) + return fileFactory(fileManager, filePath) } .compactMap({$0}) } return count == 0 ? files : Array(files.prefix(count)) } - - func createVideoFile(for filePath: URL) -> VideoFile? { - guard - let fileAttributes = try? fileManager.attributesOfItem(atPath: filePath.path), - let creationDate = fileAttributes[.creationDate] as? Date - else { - Logger.log(message: "Cannot access to saved video file") - return nil - } - - return VideoFileGenerator.generate(with: filePath, date: creationDate) - } } diff --git a/Sources/Aespa/Data/Proxy/URLCacheStorage.swift b/Sources/Aespa/Data/Proxy/URLCacheStorage.swift index dde0402..42495ff 100644 --- a/Sources/Aespa/Data/Proxy/URLCacheStorage.swift +++ b/Sources/Aespa/Data/Proxy/URLCacheStorage.swift @@ -7,17 +7,7 @@ import Foundation -protocol URLCaching { - associatedtype File - - func get(_ url: URL) -> File? - func store(_ file: File, at filePath: URL) - func empty() - - var all: [File] { get } -} - -final class URLCacheStorage: URLCaching { +class URLCacheStorage { private var storage: [URL: File] init() { diff --git a/Sources/Aespa/Processor/AespaProcessing.swift b/Sources/Aespa/Processor/AespaProcessing.swift index b4422bb..f15c462 100644 --- a/Sources/Aespa/Processor/AespaProcessing.swift +++ b/Sources/Aespa/Processor/AespaProcessing.swift @@ -18,8 +18,10 @@ protocol AespaMovieFileOutputProcessing { } protocol AespaAssetProcessing { - func process( - _ photoLibrary: T, _ assetCollection: U + func process( + _ library: Library, + _ collection: Collection ) async throws - where T: AespaAssetLibraryRepresentable, U: AespaAssetCollectionRepresentable + where Library: AespaAssetLibraryRepresentable, + Collection: AespaAssetCollectionRepresentable } diff --git a/Sources/Aespa/Processor/Asset/PhotoAssetAdditionProcessor.swift b/Sources/Aespa/Processor/Asset/PhotoAssetAdditionProcessor.swift index fe63ce3..bf24d55 100644 --- a/Sources/Aespa/Processor/Asset/PhotoAssetAdditionProcessor.swift +++ b/Sources/Aespa/Processor/Asset/PhotoAssetAdditionProcessor.swift @@ -12,10 +12,10 @@ struct PhotoAssetAdditionProcessor: AespaAssetProcessing { let imageData: Data func process< - T: AespaAssetLibraryRepresentable, U: AespaAssetCollectionRepresentable + Library: AespaAssetLibraryRepresentable, Collection: AespaAssetCollectionRepresentable >( - _ photoLibrary: T, - _ assetCollection: U + _ photoLibrary: Library, + _ assetCollection: Collection ) async throws { guard case .authorized = await photoLibrary.requestAuthorization(for: .addOnly) diff --git a/Sources/Aespa/Util/Video/Album/AlbumImporter.swift b/Sources/Aespa/Util/Album/AlbumImporter.swift similarity index 100% rename from Sources/Aespa/Util/Video/Album/AlbumImporter.swift rename to Sources/Aespa/Util/Album/AlbumImporter.swift diff --git a/Sources/Aespa/Util/Video/Authorization/AuthorizationChecker.swift b/Sources/Aespa/Util/Authorization/AuthorizationChecker.swift similarity index 100% rename from Sources/Aespa/Util/Video/Authorization/AuthorizationChecker.swift rename to Sources/Aespa/Util/Authorization/AuthorizationChecker.swift diff --git a/Sources/Aespa/Util/File/FilePathProvider.swift b/Sources/Aespa/Util/File/FilePathProvider.swift new file mode 100644 index 0000000..d0243b8 --- /dev/null +++ b/Sources/Aespa/Util/File/FilePathProvider.swift @@ -0,0 +1,60 @@ +// +// VideoFilePathProvidingService.swift +// +// +// Created by Young Bin on 2023/05/25. +// + +import UIKit + +struct FilePathProvider { + static func requestFilePath( + from fileManager: FileManager, + directoryName: String, + subDirectoryName: String, + fileName: String, + extension: String? = nil + ) throws -> URL { + let directoryPath = try requestDirectoryPath( + from: fileManager, + directoryName: directoryName, + subDirectoryName: subDirectoryName) + + let filePath = directoryPath + .appendingPathComponent(fileName) + .appendingPathExtension(`extension` ?? "") + + return filePath + } + + static func requestDirectoryPath( + from fileManager: FileManager, + directoryName: String, + subDirectoryName: String? = nil + ) throws -> URL { + guard + let albumPath = fileManager.urls( + for: .documentDirectory, + in: .userDomainMask) + .first + else { + throw AespaError.album(reason: .unabledToAccess) + } + + var directoryPathURL = albumPath.appendingPathComponent(directoryName, isDirectory: true) + + if let subDirectoryName = subDirectoryName { + directoryPathURL = directoryPathURL.appendingPathComponent(subDirectoryName, isDirectory: true) + } + + // Create directory if doesn't exist + if !fileManager.fileExists(atPath: directoryPathURL.path) { + try fileManager.createDirectory( + at: directoryPathURL, + withIntermediateDirectories: true, + attributes: nil) + } + + return directoryPathURL + } +} diff --git a/Sources/Aespa/Util/File/PhotoFileGenerator.swift b/Sources/Aespa/Util/File/PhotoFileGenerator.swift new file mode 100644 index 0000000..f96e280 --- /dev/null +++ b/Sources/Aespa/Util/File/PhotoFileGenerator.swift @@ -0,0 +1,30 @@ +// +// File.swift +// +// +// Created by 이영빈 on 2023/06/18. +// + +import UIKit +import Foundation + +struct PhotoFileGenerator { + static func generate(with path: URL, date: Date) -> PhotoFile { + return PhotoFile( + generatedDate: date, + path: path, + thumbnail: PhotoFileGenerator.generateThumbnail(for: path)) + } + + static func generateThumbnail(for path: URL) -> UIImage? { + guard let data = try? Data(contentsOf: path) else { + return nil + } + + guard let compressedImageData = UIImage(data: data)?.jpegData(compressionQuality: 0.5) else { + return nil + } + + return UIImage(data: compressedImageData) + } +} diff --git a/Sources/Aespa/Util/Video/File/VideoFileGenerator.swift b/Sources/Aespa/Util/File/VideoFileGenerator.swift similarity index 71% rename from Sources/Aespa/Util/Video/File/VideoFileGenerator.swift rename to Sources/Aespa/Util/File/VideoFileGenerator.swift index d399c11..4b8939a 100644 --- a/Sources/Aespa/Util/Video/File/VideoFileGenerator.swift +++ b/Sources/Aespa/Util/File/VideoFileGenerator.swift @@ -1,5 +1,5 @@ // -// File.swift +// VideoFileGenerator.swift // // // Created by Young Bin on 2023/05/27. @@ -21,13 +21,18 @@ struct VideoFileGenerator { let imageGenerator = AVAssetImageGenerator(asset: asset) imageGenerator.appliesPreferredTrackTransform = true - imageGenerator.maximumSize = .init(width: 250, height: 250) + imageGenerator.apertureMode = .cleanAperture do { let cgImage = try imageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) - let thumbnail = UIImage(cgImage: cgImage) - + + let rawThumbnail = UIImage(cgImage: cgImage) + guard let compressedData = rawThumbnail.jpegData(compressionQuality: 0.5) else { + return nil + } + + let thumbnail = UIImage(data: compressedData) return thumbnail } catch let error { Logger.log(error: error) diff --git a/Sources/Aespa/Util/Video/File/PhotoFileGenerator.swift b/Sources/Aespa/Util/Video/File/PhotoFileGenerator.swift deleted file mode 100644 index 2f00a83..0000000 --- a/Sources/Aespa/Util/Video/File/PhotoFileGenerator.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// File.swift -// -// -// Created by 이영빈 on 2023/06/18. -// - -import UIKit -import Foundation - -struct PhotoFileGenerator { - static func generate(data: Data, date: Date) -> PhotoFile { - return PhotoFile( - data: data, - generatedDate: date, - thumbnail: UIImage(data: data)) - } -} diff --git a/Sources/Aespa/Util/Video/File/VideoFilePathProvider.swift b/Sources/Aespa/Util/Video/File/VideoFilePathProvider.swift deleted file mode 100644 index 7e97437..0000000 --- a/Sources/Aespa/Util/Video/File/VideoFilePathProvider.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// VideoFilePathProvidingService.swift -// -// -// Created by Young Bin on 2023/05/25. -// - -import UIKit - -struct VideoFilePathProvider { - static func requestFilePath( - from fileManager: FileManager, - directoryName: String, - fileName: String, - extension: String - ) throws -> URL { - let directoryPath = try requestDirectoryPath(from: fileManager, name: directoryName) - let filePath = directoryPath - .appendingPathComponent(fileName) - .appendingPathExtension(`extension`) - - return filePath - } - - static func requestDirectoryPath(from fileManager: FileManager, name: String) throws -> URL { - guard - let albumPath = fileManager.urls(for: .documentDirectory, - in: .userDomainMask).first - else { - throw AespaError.album(reason: .unabledToAccess) - } - - let directoryPathURL = albumPath.appendingPathComponent(name, isDirectory: true) - - // Set directory if doesn't exist - if fileManager.fileExists(atPath: directoryPathURL.path) == false { - try fileManager.createDirectory( - atPath: directoryPathURL.path, - withIntermediateDirectories: true, - attributes: nil) - } - - return directoryPathURL - } -} diff --git a/Tests/TestHostApp.xcodeproj/project.pbxproj b/Tests/TestHostApp.xcodeproj/project.pbxproj index dc4c9aa..5d4a45f 100644 --- a/Tests/TestHostApp.xcodeproj/project.pbxproj +++ b/Tests/TestHostApp.xcodeproj/project.pbxproj @@ -25,7 +25,10 @@ 9C727D0F2A3FEF9800EF9472 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9C727D0E2A3FEF9800EF9472 /* Preview Assets.xcassets */; }; 9C727D502A3FF03200EF9472 /* Cuckoo in Frameworks */ = {isa = PBXBuildFile; productRef = 9C727D4F2A3FF03200EF9472 /* Cuckoo */; }; 9C727D572A3FF0B100EF9472 /* Aespa in Frameworks */ = {isa = PBXBuildFile; productRef = 9C727D562A3FF0B100EF9472 /* Aespa */; }; - 9CF0FE2D2A40574200FEE8C9 /* VideoFileCachingProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF0FE2C2A40574200FEE8C9 /* VideoFileCachingProxyTests.swift */; }; + 9CD12FFA2A452FA10012D1E1 /* URLCacheStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD12FF92A452FA10012D1E1 /* URLCacheStorageTests.swift */; }; + 9CD12FFC2A454AC40012D1E1 /* GeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD12FFB2A454AC40012D1E1 /* GeneratorTests.swift */; }; + 9CD12FFE2A454B770012D1E1 /* MockImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CD12FFD2A454B770012D1E1 /* MockImage.swift */; }; + 9CF0FE2D2A40574200FEE8C9 /* FileCachingProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF0FE2C2A40574200FEE8C9 /* FileCachingProxyTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -59,7 +62,10 @@ 9C727D0E2A3FEF9800EF9472 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 9C727D142A3FEF9900EF9472 /* TestHostAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestHostAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9C727D542A3FF09400EF9472 /* Aespa */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Aespa; path = ..; sourceTree = ""; }; - 9CF0FE2C2A40574200FEE8C9 /* VideoFileCachingProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoFileCachingProxyTests.swift; sourceTree = ""; }; + 9CD12FF92A452FA10012D1E1 /* URLCacheStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLCacheStorageTests.swift; sourceTree = ""; }; + 9CD12FFB2A454AC40012D1E1 /* GeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneratorTests.swift; sourceTree = ""; }; + 9CD12FFD2A454B770012D1E1 /* MockImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockImage.swift; sourceTree = ""; }; + 9CF0FE2C2A40574200FEE8C9 /* FileCachingProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCachingProxyTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -109,6 +115,7 @@ children = ( 9C4BBE512A400E450071C84F /* AlbumUtilTests.swift */, 9C4BBE522A400E450071C84F /* FileUtilTests.swift */, + 9CD12FFB2A454AC40012D1E1 /* GeneratorTests.swift */, ); path = Util; sourceTree = ""; @@ -117,19 +124,20 @@ isa = PBXGroup; children = ( 9C4BBE652A400F830071C84F /* GeneratedMocks.swift */, - 9C4BBE542A400E450071C84F /* Video */, + 9C4BBE542A400E450071C84F /* Asset */, 9C4BBE572A400E450071C84F /* MockFileManager.swift */, + 9C4BBE562A400E450071C84F /* MockVideo.swift */, + 9CD12FFD2A454B770012D1E1 /* MockImage.swift */, ); path = Mock; sourceTree = ""; }; - 9C4BBE542A400E450071C84F /* Video */ = { + 9C4BBE542A400E450071C84F /* Asset */ = { isa = PBXGroup; children = ( 9C4BBE552A400E450071C84F /* video.mp4 */, - 9C4BBE562A400E450071C84F /* MockVideo.swift */, ); - path = Video; + path = Asset; sourceTree = ""; }; 9C4BBE582A400E450071C84F /* Processor */ = { @@ -200,7 +208,8 @@ 9CF0FE2B2A40573000FEE8C9 /* Data */ = { isa = PBXGroup; children = ( - 9CF0FE2C2A40574200FEE8C9 /* VideoFileCachingProxyTests.swift */, + 9CF0FE2C2A40574200FEE8C9 /* FileCachingProxyTests.swift */, + 9CD12FF92A452FA10012D1E1 /* URLCacheStorageTests.swift */, ); path = Data; sourceTree = ""; @@ -365,10 +374,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9CF0FE2D2A40574200FEE8C9 /* VideoFileCachingProxyTests.swift in Sources */, + 9CF0FE2D2A40574200FEE8C9 /* FileCachingProxyTests.swift in Sources */, 9C4BBE5C2A400E450071C84F /* DeviceTunerTests.swift in Sources */, 9C4BBE612A400E450071C84F /* MockVideo.swift in Sources */, + 9CD12FFC2A454AC40012D1E1 /* GeneratorTests.swift in Sources */, 9C4BBE5E2A400E450071C84F /* AlbumUtilTests.swift in Sources */, + 9CD12FFA2A452FA10012D1E1 /* URLCacheStorageTests.swift in Sources */, 9C4BBE5F2A400E450071C84F /* FileUtilTests.swift in Sources */, 9C4BBE622A400E450071C84F /* MockFileManager.swift in Sources */, 9C4BBE5B2A400E450071C84F /* SessionTunerTests.swift in Sources */, @@ -376,6 +387,7 @@ 9C4BBE6C2A4013F90071C84F /* AssetProcessorTests.swift in Sources */, 9C4BBE5D2A400E450071C84F /* ConnectionTunerTests.swift in Sources */, 9C4BBE682A4011460071C84F /* CapturePhotoOutputProcessorTests.swift in Sources */, + 9CD12FFE2A454B770012D1E1 /* MockImage.swift in Sources */, 9C4BBE662A400F830071C84F /* GeneratedMocks.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tests/TestHostApp.xcodeproj/xcshareddata/xcschemes/TestHostApp.xcscheme b/Tests/TestHostApp.xcodeproj/xcshareddata/xcschemes/TestHostApp.xcscheme index fdadaf4..2115644 100644 --- a/Tests/TestHostApp.xcodeproj/xcshareddata/xcschemes/TestHostApp.xcscheme +++ b/Tests/TestHostApp.xcodeproj/xcshareddata/xcschemes/TestHostApp.xcscheme @@ -26,12 +26,15 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + skipped = "NO"> ! + + var mockCache: MockURLCacheStorage! + var mockFileManager: MockFileManager! + + let fileFactory: (FileManager, URL) -> String? = { (_, url) in + url.lastPathComponent + } + + override func setUpWithError() throws { + mockFileManager = MockFileManager() + mockCache = MockURLCacheStorage() + + // Mock file maanager simulates like it has the `givenFile` + mockFileManager.urlsStub = [givenDirectoryPath] + mockFileManager.contentsOfDirectoryStub = [givenFile] + + // Default stub + let expectedFile = givenFile + let expectedFilePath = givenFilePath + stub(mockCache) { proxy in + when(proxy.store(equal(to: expectedFile), at: equal(to: expectedFilePath))).thenDoNothing() + when(proxy.get(equal(to: expectedFilePath))).thenReturn(expectedFile) + when(proxy.all.get).thenReturn([expectedFile]) + when(proxy.empty()).thenDoNothing() + } + } + + override func tearDownWithError() throws { + sut = nil + mockFileManager = nil + } + + func testFetchWithoutCount() { + let givenFiles = ["File1.txt", "File2.txt", "File3.txt"] + + mockFileManager.contentsOfDirectoryStub = givenFiles + + sut = FileCachingProxy(fileDirectory: givenAlbumDirectory, + enableCaching: false, + fileManager: mockFileManager, + cacheStorage: mockCache, + fileFactory: fileFactory) + + let fetchedFiles = sut.fetch() + XCTAssertEqual(fetchedFiles.count, givenFiles.count) + } + + func testFetchWithCount() { + let givenFiles = ["File1.txt", "File2.txt", "File3.txt"] + + mockFileManager.contentsOfDirectoryStub = givenFiles + + sut = FileCachingProxy(fileDirectory: givenAlbumDirectory, + enableCaching: false, + fileManager: mockFileManager, + cacheStorage: mockCache, + fileFactory: fileFactory) + + let fetchedFiles = sut.fetch(count: 2) + XCTAssertEqual(fetchedFiles.count, 2) + } + + func testInvalidate() { + sut = FileCachingProxy(fileDirectory: givenAlbumDirectory, + enableCaching: true, + fileManager: mockFileManager, + cacheStorage: mockCache, + fileFactory: fileFactory) + + sut.invalidate() + + XCTAssertNil(sut.lastModificationDate) + verify(mockCache).empty().with(returnType: Void.self) + } + + func testRenew() { + sut = FileCachingProxy(fileDirectory: givenAlbumDirectory, + enableCaching: true, + fileManager: mockFileManager, + cacheStorage: mockCache, + fileFactory: fileFactory) + // Given a file + mockFileManager.contentsOfDirectoryStub = [givenFile] + + // Given empty cache + stub(mockCache) { proxy in + when(proxy.get(any(URL.self))).thenReturn(nil) + when(proxy.all.get).thenReturn([]) + } + + print(givenFilePath) + let expectedFile = givenFile + let expectedFilePath = givenFilePath + stub(mockCache) { proxy in + when(proxy.store(equal(to: expectedFile), at: equal(to: expectedFilePath))).thenDoNothing() + } + + // When renew is called + sut.renew() + + // Then cache should have been updated + verify(mockCache) + .store(equal(to: expectedFile), at: equal(to: expectedFilePath)) + .with(returnType: Void.self) + } + + func testProxyCache() { + let expectedFile = givenFile + let expectedFilePath = givenFilePath + let modificationDate = Date() + + mockFileManager.contentsOfDirectoryStub = [expectedFile] + mockFileManager.attributesOfItemStub = [.modificationDate: modificationDate, .creationDate: Date()] + + // Cache will be filled with a data it has in the `FileManager` + sut = FileCachingProxy(fileDirectory: givenAlbumDirectory, + enableCaching: true, + fileManager: mockFileManager, + cacheStorage: mockCache, + fileFactory: fileFactory) + + // Given empty cache + stub(mockCache) { proxy in + when(proxy.get(equal(to: expectedFilePath))).thenReturn(nil) + when(proxy.all.get).thenReturn([]) + } + + // When renew the cache + sut.renew() + + // Then + verify(mockCache).get(equal(to: expectedFilePath)).with(returnType: File?.self) + verify(mockCache).store(equal(to: expectedFile), at: equal(to: expectedFilePath)).with(returnType: Void.self) + + // Given cache filled + stub(mockCache) { proxy in + when(proxy.get(equal(to: expectedFilePath))).thenReturn(expectedFile) + when(proxy.all.get).thenReturn([expectedFile]) + } + + // When fetch the files + let fetchedFiles = sut.fetch(count: 1) + + // Then... + verify(mockCache, times(1)) // Only includes previous invoke - which means store's not called additionally + .store(equal(to: expectedFile), at: equal(to: expectedFilePath)).with(returnType: Void.self) + + XCTAssertEqual(sut.lastModificationDate, modificationDate) + XCTAssertEqual(fetchedFiles.first, expectedFile, "Fetched file should match the initially cached file") + } + + func testProxyNotUsingCache() { + let expectedFile = givenFile + + // Cache shouldn't be filled with a data it has in the `FileManager` + sut = FileCachingProxy(fileDirectory: givenAlbumDirectory, + enableCaching: false, + fileManager: mockFileManager, + cacheStorage: mockCache, + fileFactory: fileFactory) + + let fetchedFiles = sut.fetch(count: 1) + + verify(mockCache, never()).store(any(File.self), at: any(URL.self)) + verify(mockCache, never()).get(any(URL.self)) + verify(mockCache, never()).all.get() + + XCTAssertTrue(mockFileManager.contentsOfDirectoryCalled) + XCTAssertEqual(fetchedFiles.first, expectedFile, "Fetched file should match the initially cached file") + } + + var givenFile: File { + "File.txt" + } + + var givenDirectoryPath: URL { + URL(fileURLWithPath: "/path/to/mock/", isDirectory: true) + } + + var givenAlbumDirectory: URL { + URL(fileURLWithPath: "\(givenDirectoryPath.relativePath)/Test", isDirectory: true) + } + + var givenFilePath: URL { + givenAlbumDirectory.appendingPathComponent(givenFile) + } + + var givenAttributes: [FileAttributeKey: Any] { + [.modificationDate: Date(), .creationDate: Date()] + } +} + diff --git a/Tests/Tests/Data/URLCacheStorageTests.swift b/Tests/Tests/Data/URLCacheStorageTests.swift new file mode 100644 index 0000000..74d2e06 --- /dev/null +++ b/Tests/Tests/Data/URLCacheStorageTests.swift @@ -0,0 +1,62 @@ +// +// URLCacheStorageTests.swift +// TestHostAppTests +// +// Created by 이영빈 on 2023/06/23. +// + +import XCTest +@testable import Aespa + +class URLCacheStorageTests: XCTestCase { + var cacheStorage: URLCacheStorage! + var testURL: URL! + + override func setUp() { + super.setUp() + cacheStorage = URLCacheStorage() + testURL = URL(string: "file://path/to/file") + } + + override func tearDown() { + cacheStorage = nil + testURL = nil + super.tearDown() + } + + func testStoreAndGet() { + let expectedValue = "Test String" + cacheStorage.store(expectedValue, at: testURL) + + let storedValue = cacheStorage.get(testURL) + + XCTAssertEqual(expectedValue, storedValue) + } + + func testEmpty() { + let value = "Test String" + cacheStorage.store(value, at: testURL) + + cacheStorage.empty() + + let retrievedValue = cacheStorage.get(testURL) + + XCTAssertNil(retrievedValue) + } + + func testAll() { + let value1 = "Test String 1" + let value2 = "Test String 2" + + let url1 = URL(string: "file://path/to/file1") + let url2 = URL(string: "file://path/to/file2") + + cacheStorage.store(value1, at: url1!) + cacheStorage.store(value2, at: url2!) + + let allValues = cacheStorage.all + let expectedValues = [value1, value2] + + XCTAssertEqual(Set(allValues), Set(expectedValues)) + } +} diff --git a/Tests/Tests/Data/VideoFileCachingProxyTests.swift b/Tests/Tests/Data/VideoFileCachingProxyTests.swift deleted file mode 100644 index 8f50149..0000000 --- a/Tests/Tests/Data/VideoFileCachingProxyTests.swift +++ /dev/null @@ -1,218 +0,0 @@ -// -// VideoFileCachingProxyTests.swift -// TestHostAppTests -// -// Created by 이영빈 on 2023/06/19. -// - -import XCTest -import AVFoundation - -import Cuckoo - -@testable import Aespa - -final class VideoFileCachingProxyTests: XCTestCase { - var sut: VideoFileCachingProxy>! - - var mockCache: MockURLCaching! - var mockFileManager: MockFileManager! - - override func setUpWithError() throws { - mockFileManager = MockFileManager() - mockCache = MockURLCaching() - - // Mock file maanager simulates like it has the `givenVideoFile` - mockFileManager.urlsStub = [givenDirectoryPath] - mockFileManager.attributesOfItemStub = givenAttributes - mockFileManager.contentsOfDirectoryStub = [givenVideoFile.path.lastPathComponent] - - // Default stub - let expectedVideoFile = givenVideoFile - let expectedFilePath = givenVideoFile.path - stub(mockCache) { proxy in - when(proxy.store(equal(to: expectedVideoFile), at: equal(to: expectedFilePath))).thenDoNothing() - when(proxy.get(equal(to: expectedFilePath))).thenReturn(expectedVideoFile) - when(proxy.all.get).thenReturn([expectedVideoFile]) - when(proxy.empty()).thenDoNothing() - } - } - - override func tearDownWithError() throws { - sut = nil - mockFileManager = nil - } - - func testFetchWithoutCount() { - let givenFiles = [ - givenVideoFile.path.lastPathComponent + "1", - givenVideoFile.path.lastPathComponent + "2", - givenVideoFile.path.lastPathComponent + "3" - ] - - mockFileManager.contentsOfDirectoryStub = givenFiles - - sut = VideoFileCachingProxy(albumDirectory: givenAlbumDirectory, - enableCaching: false, - fileManager: mockFileManager, - cacheStorage: mockCache) - - let fetchedFiles = sut.fetch() - XCTAssertEqual(fetchedFiles.count, givenFiles.count) - } - - func testFetchWithCount() { - let givenFiles = [ - givenVideoFile.path.lastPathComponent + "1", - givenVideoFile.path.lastPathComponent + "2", - givenVideoFile.path.lastPathComponent + "3" - ] - - mockFileManager.contentsOfDirectoryStub = givenFiles - - sut = VideoFileCachingProxy(albumDirectory: givenAlbumDirectory, - enableCaching: false, - fileManager: mockFileManager, - cacheStorage: mockCache) - - let fetchedFiles = sut.fetch(count: 2) - XCTAssertEqual(fetchedFiles.count, 2) - } - - func testInvalidate() { - sut = VideoFileCachingProxy(albumDirectory: givenAlbumDirectory, - enableCaching: true, - fileManager: mockFileManager, - cacheStorage: mockCache) - - sut.invalidate() - - XCTAssertNil(sut.lastModificationDate) - verify(mockCache) - .empty() - .with(returnType: Void.self) - } - - func testRenew() { - sut = VideoFileCachingProxy(albumDirectory: givenAlbumDirectory, - enableCaching: true, - fileManager: mockFileManager, - cacheStorage: mockCache) - // Given a file - mockFileManager.contentsOfDirectoryStub = [givenVideoFile.path.lastPathComponent] - - // Given empty cache - stub(mockCache) { proxy in - when(proxy.get(any(URL.self))).thenReturn(nil) - when(proxy.all.get).thenReturn([]) - } - - // When renew is called - sut.renew() - - // Then cache should be updated... - let expectedFile = givenVideoFile - verify(mockCache) - .store(equal(to: expectedFile), at: equal(to: expectedFile.path)) - .with(returnType: Void.self) - } - - func testProxyCache() { - let expectedVideoFile = givenVideoFile - let expectedFilePath = givenVideoFile.path - let modificationDate = Date() - - mockFileManager.contentsOfDirectoryStub = [expectedFilePath.lastPathComponent] - mockFileManager.attributesOfItemStub = [.modificationDate: modificationDate, - .creationDate: Date()] - - // Cache will be filled with a data it has in the `FileManager` - sut = VideoFileCachingProxy(albumDirectory: givenAlbumDirectory, - enableCaching: true, - fileManager: mockFileManager, - cacheStorage: mockCache) - - // Given empty cache - stub(mockCache) { proxy in - when(proxy.get(equal(to: expectedFilePath))).thenReturn(nil) - when(proxy.all.get).thenReturn([]) - } - - // When renew the cache - sut.renew() - - // Then... - verify(mockCache) - .get(equal(to: expectedFilePath)) - .with(returnType: VideoFile?.self) - - verify(mockCache) - .store(equal(to: expectedVideoFile), at: equal(to: expectedFilePath)) - .with(returnType: Void.self) - - - // Given cache filled - stub(mockCache) { proxy in - when(proxy.get(equal(to: expectedFilePath))).thenReturn(expectedVideoFile) - when(proxy.all.get).thenReturn([expectedVideoFile]) - } - - // When fetch the files - let fetchedFiles = sut.fetch(count: 1) - - // Then... - verify(mockCache, times(1)) // Only includes previous invoke - which means store's not called additionally - .store(equal(to: expectedVideoFile), at: equal(to: expectedFilePath)) - .with(returnType: Void.self) - - XCTAssertEqual(sut.lastModificationDate, modificationDate) - XCTAssertEqual(fetchedFiles.first, expectedVideoFile, "Fetched file should match the initially cached file") - } - - func testProxyNotUsingCache() { - let expectedVideoFile = givenVideoFile - - // Cache shouldn't be filled with a data it has in the `FileManager` - sut = VideoFileCachingProxy(albumDirectory: givenAlbumDirectory, - enableCaching: false, - fileManager: mockFileManager, - cacheStorage: mockCache) - - let fetchedFiles = sut.fetch(count: 1) - - verify(mockCache, never()) - .store(any(VideoFile.self), at: any(URL.self)) - - verify(mockCache, never()) - .get(any(URL.self)) - - verify(mockCache, never()) - .all.get() - - XCTAssertTrue(mockFileManager.contentsOfDirectoryCalled) - XCTAssertEqual(fetchedFiles.first, expectedVideoFile, "Fetched file should match the initially cached file") - } -} - -fileprivate extension VideoFileCachingProxyTests { - var givenFilename: String { - "File" - } - - var givenDirectoryPath: URL { - URL(fileURLWithPath: "/path/to/mock/", isDirectory: true) - } - - var givenAlbumDirectory: URL { - URL(fileURLWithPath: "\(givenDirectoryPath.relativePath)/Test", isDirectory: true) - } - - var givenVideoFile: VideoFile { - VideoFile(generatedDate: Date(), - path: givenAlbumDirectory.appendingPathComponent(givenFilename)) - } - - var givenAttributes: [FileAttributeKey: Any] { - [.modificationDate: Date(), .creationDate: Date()] - } -} diff --git a/Tests/Tests/Mock/Video/video.mp4 b/Tests/Tests/Mock/Asset/video.mp4 similarity index 100% rename from Tests/Tests/Mock/Video/video.mp4 rename to Tests/Tests/Mock/Asset/video.mp4 diff --git a/Tests/Tests/Mock/MockImage.swift b/Tests/Tests/Mock/MockImage.swift new file mode 100644 index 0000000..0e1fc7a --- /dev/null +++ b/Tests/Tests/Mock/MockImage.swift @@ -0,0 +1,25 @@ +// +// MockImage.swift +// TestHostAppTests +// +// Created by 이영빈 on 2023/06/23. +// + +import UIKit +import Foundation + +class MockImage { + let dirPath: URL + let path: URL + + init() { + let fileName = "image" + + dirPath = FileManager.default.temporaryDirectory + path = dirPath.appendingPathComponent(fileName) + + let imageData = UIImage(systemName: "person")!.pngData()! + try! imageData.write(to: path) + } +} + diff --git a/Tests/Tests/Mock/Video/MockVideo.swift b/Tests/Tests/Mock/MockVideo.swift similarity index 100% rename from Tests/Tests/Mock/Video/MockVideo.swift rename to Tests/Tests/Mock/MockVideo.swift diff --git a/Tests/Tests/Util/AlbumUtilTests.swift b/Tests/Tests/Util/AlbumUtilTests.swift index 875b575..98045ab 100644 --- a/Tests/Tests/Util/AlbumUtilTests.swift +++ b/Tests/Tests/Util/AlbumUtilTests.swift @@ -53,7 +53,6 @@ final class AlbumUtilTests: XCTestCase { func testAlbumImporter_albumNotExists() throws { let albumName = "test" let options = PHFetchOptions() - let mockAlbum = MockAespaAssetCollectionRepresentable() // Success case stub(mockLibrary) { proxy in diff --git a/Tests/Tests/Util/FileUtilTests.swift b/Tests/Tests/Util/FileUtilTests.swift index edcc85f..09ccace 100644 --- a/Tests/Tests/Util/FileUtilTests.swift +++ b/Tests/Tests/Util/FileUtilTests.swift @@ -44,20 +44,22 @@ final class FileGeneratorTests: XCTestCase { func testRequestDirPath() throws { let albumName = "Test" - let dirPath = try VideoFilePathProvider.requestDirectoryPath(from: mockFileManager, name: albumName) + let dirPath = try FilePathProvider.requestDirectoryPath(from: mockFileManager, directoryName: albumName) XCTAssertEqual(dirPath.lastPathComponent, albumName) } func testRequestFilePath() throws { let albumName = "Test" + let subDirName = "Sub" let fileName = "Testfile" let `extension` = "mp4" - let expectedSuffix = "/\(albumName)/\(fileName).\(`extension`)" + let expectedSuffix = "/\(albumName)/\(subDirName)/\(fileName).\(`extension`)" - let filePath = try VideoFilePathProvider.requestFilePath( + let filePath = try FilePathProvider.requestFilePath( from: mockFileManager, directoryName: albumName, + subDirectoryName: subDirName, fileName: fileName, extension: `extension`) diff --git a/Tests/Tests/Util/GeneratorTests.swift b/Tests/Tests/Util/GeneratorTests.swift new file mode 100644 index 0000000..6c01c5c --- /dev/null +++ b/Tests/Tests/Util/GeneratorTests.swift @@ -0,0 +1,70 @@ +// +// GeneratorTests.swift +// TestHostAppTests +// +// Created by 이영빈 on 2023/06/23. +// + +import XCTest + +@testable import Aespa + + +final class GeneratorTests: XCTestCase { + var mockVideo: MockVideo! + var mockImage: MockImage! + + override func setUpWithError() throws { + mockVideo = MockVideo() + mockImage = MockImage() + } + + override func tearDownWithError() throws { + mockVideo = nil + mockImage = nil + } + + func testVideoFile_generate() { + guard let path = mockVideo.path else { + XCTFail("Unable to get the mock video path") + return + } + + let date = Date() + let videoFile = VideoFileGenerator.generate(with: path, date: date) + + XCTAssertEqual(videoFile.generatedDate, date) + XCTAssertEqual(videoFile.path, path) + XCTAssertNotNil(videoFile.thumbnail) // Thumbnail should be generated when init + } + + func testVideoFile_generateThumbnail() { + guard let path = mockVideo.path else { + XCTFail("Unable to get the mock video path") + return + } + + let thumbnail = VideoFileGenerator.generateThumbnail(for: path) + + XCTAssertNotNil(thumbnail) + } + + func testPhotoFile_generate() { + let path = mockImage.path + + let date = Date() + let photoFile = PhotoFileGenerator.generate(with: path, date: date) + + XCTAssertEqual(photoFile.generatedDate, date) + XCTAssertEqual(photoFile.path, path) + XCTAssertNotNil(photoFile.thumbnail) // Thumbnail should be generated when init + } + + func testPhotoFile_generateThumbnail() { + let path = mockImage.path + + let thumbnail = PhotoFileGenerator.generateThumbnail(for: path) + + XCTAssertNotNil(thumbnail) + } +}