diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml deleted file mode 100644 index 747550a..0000000 --- a/.github/workflows/unit-test.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Unit testing and collect result infomations - -on: - pull_request: - types: [opened, synchronize] - -concurrency: - group: unit-test-${{ github.head_ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: macOS-latest - - steps: - - uses: actions/checkout@v3 - - - name: Select Xcode - run: sudo xcode-select -s '/Applications/Xcode.app/Contents/Developer' - - - name: Prepare test - run: | - cd ./Scripts - ROOT_PATH="../" - - ROOT_DIR_NAME=$(basename "$(cd "$ROOT_PATH" && pwd)") - if [ "$ROOT_DIR_NAME" != "Aespa" ]; then - echo "❌ Error: Script's not called in proper path." - exit 1 - fi - - # Generate mocks - chmod +x ./gen-mocks.sh - ./gen-mocks.sh - - - name: Test - run: | - set -o pipefail - ROOT_PATH="./" - - # Now do test - DERIVED_DATA_PATH="./DerivedData" - PROJECT_NAME="TestHostApp" - TEST_SCHEME="Test" - - xcodebuild test \ - -verbose \ - -project ${ROOT_PATH}/Tests/${PROJECT_NAME}.xcodeproj \ - -scheme ${TEST_SCHEME} \ - -destination 'platform=iOS Simulator,name=iPhone 14 Pro,OS=latest' \ - -derivedDataPath ${DERIVED_DATA_PATH} \ - -enableCodeCoverage YES \ - | xcpretty --color \ - || exit 1 - - # Check if the tests failed - if [ $? -ne 0 ]; then - echo "❌ Error: Tests failed." - exit 1 - fi \ No newline at end of file diff --git a/Demo/Aespa-iOS/VideoContentViewModel.swift b/Demo/Aespa-iOS/VideoContentViewModel.swift index cec1e14..c865d9b 100644 --- a/Demo/Aespa-iOS/VideoContentViewModel.swift +++ b/Demo/Aespa-iOS/VideoContentViewModel.swift @@ -31,6 +31,7 @@ class VideoContentViewModel: ObservableObject { @Published var photoFiles: [PhotoAsset] = [] init() { + // If you don't want to make an album, you can set `albumName` to `nil` let option = AespaOption(albumName: "YOUR_ALBUM_NAME") self.aespaSession = Aespa.session(with: option) @@ -81,6 +82,40 @@ class VideoContentViewModel: ObservableObject { } .assign(to: \.photoAlbumCover, on: self) .store(in: &subscription) + + aespaSession.videoAssetEventPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + guard let self else { return } + + if case .deleted = event { + self.fetchVideoFiles() + + // It works, but not recommended + // videoFiles.remove(assets) + + // Update thumbnail + self.videoAlbumCover = self.videoFiles.first?.thumbnailImage + } + } + .store(in: &subscription) + + aespaSession.photoAssetEventPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + guard let self else { return } + + if case .deleted = event { + self.fetchPhotoFiles() + + // It works, but not recommended + // photoFiles.remove(assets) + + // Update thumbnail + self.photoAlbumCover = self.photoFiles.first?.image + } + } + .store(in: &subscription) } func fetchVideoFiles() { diff --git a/Sources/Aespa/AespaOption.swift b/Sources/Aespa/AespaOption.swift index 1087568..66c6541 100644 --- a/Sources/Aespa/AespaOption.swift +++ b/Sources/Aespa/AespaOption.swift @@ -30,8 +30,9 @@ public struct AespaOption { /// /// - Parameters: /// - albumName: The name of the album where recorded videos will be saved. + /// If you don't want to make an album, you can set this value to `nil` /// - enableLogging: A Boolean value that determines whether logging is enabled. - public init(albumName: String, enableLogging: Bool = true) { + public init(albumName: String?, enableLogging: Bool = true) { self.init( asset: Asset(albumName: albumName), session: Session(), @@ -73,18 +74,24 @@ public extension AespaOption { public var fileExtension: String init( - albumName: String, + albumName: String?, videoDirectoryName: String = "video", photoDirectoryName: String = "photo", synchronizeWithLocalAlbum: Bool = true, fileExtension: FileExtension = .mp4, fileNameHandler: @escaping FileNamingRule = FileNamingRulePreset.Timestamp().rule ) { - self.albumName = albumName + if let albumName { + self.albumName = albumName + self.synchronizeWithLocalAlbum = synchronizeWithLocalAlbum + } else { + Logger.log(message: "Album name is not specified. It will not save and load any assets.") + self.albumName = "Temp" // Use a temporary album name + self.synchronizeWithLocalAlbum = false + } self.videoDirectoryName = videoDirectoryName self.photoDirectoryName = photoDirectoryName - self.synchronizeWithLocalAlbum = true self.fileExtension = fileExtension.rawValue self.fileNameHandler = fileNameHandler } diff --git a/Sources/Aespa/AespaSession.swift b/Sources/Aespa/AespaSession.swift index 979665f..07910cc 100644 --- a/Sources/Aespa/AespaSession.swift +++ b/Sources/Aespa/AespaSession.swift @@ -23,6 +23,7 @@ open class AespaSession { let option: AespaOption let coreSession: AespaCoreSession private let albumManager: AespaCoreAlbumManager + private let eventManager: AespaEventManager private let recorder: AespaCoreRecorder private let camera: AespaCoreCamera @@ -38,17 +39,21 @@ open class AespaSession { /// /// - Note: If you're looking for a `View` for `SwiftUI`, use `preview` public let previewLayer: AVCaptureVideoPreviewLayer - + convenience init(option: AespaOption) { let session = AespaCoreSession(option: option) + let eventManager = AespaEventManager() self.init( option: option, session: session, recorder: .init(core: session), camera: .init(core: session), - albumManager: .init(albumName: option.asset.albumName) - ) + albumManager: .init( + albumName: option.asset.albumName, + videoAssetEventSubject: eventManager.videoAssetEventPublihser, + photoAssetEventSubject: eventManager.photoAssetEventPublihser), + eventManager: eventManager) } init( @@ -56,13 +61,15 @@ open class AespaSession { session: AespaCoreSession, recorder: AespaCoreRecorder, camera: AespaCoreCamera, - albumManager: AespaCoreAlbumManager + albumManager: AespaCoreAlbumManager, + eventManager: AespaEventManager ) { self.option = option self.coreSession = session self.recorder = recorder self.camera = camera self.albumManager = albumManager + self.eventManager = eventManager self.previewLayerSubject = .init(nil) @@ -132,6 +139,19 @@ open class AespaSession { return device.position } + /// Publishes events related to video assets, + /// allowing subscribers to react to delete or add event in video assets. + public var videoAssetEventPublisher: AnyPublisher { + eventManager.videoAssetEventPublihser.eraseToAnyPublisher() + } + + + /// Publishes events related to photo assets, + /// allowing subscribers to react to delete or add event in photo assets. + public var photoAssetEventPublisher: AnyPublisher { + eventManager.photoAssetEventPublihser.eraseToAnyPublisher() + } + /// This property indicates whether the camera device is set to monitor changes in the subject area. /// /// Enabling subject area change monitoring allows the device to adjust focus and exposure settings automatically diff --git a/Sources/Aespa/Core/AespaCoreAlbumManager.swift b/Sources/Aespa/Core/AespaCoreAlbumManager.swift index b4de04e..f078fc0 100644 --- a/Sources/Aespa/Core/AespaCoreAlbumManager.swift +++ b/Sources/Aespa/Core/AespaCoreAlbumManager.swift @@ -9,27 +9,48 @@ import Photos /// Retreive the video(url) from `FileManager` based local storage /// and add the video to the pre-defined album roll -class AespaCoreAlbumManager { +class AespaCoreAlbumManager: NSObject { // Dependencies private let cachingProxy: AssetCachingProxy private let photoLibrary: PHPhotoLibrary private let albumName: String + private let videoAssetEventSubject: AssetEventSubject + private let photoAssetEventSubject: AssetEventSubject + private var album: PHAssetCollection? - - convenience init(albumName: String) { + private var latestVideoFetchResult: PHFetchResult? + private var latestPhotoFetchResult: PHFetchResult? + + convenience init( + albumName: String, + videoAssetEventSubject: AssetEventSubject, + photoAssetEventSubject: AssetEventSubject + ) { let photoLibrary = PHPhotoLibrary.shared() let cachingProxy = AssetCachingProxy() - self.init(albumName: albumName, cachingProxy, photoLibrary) + self.init( + albumName: albumName, + cachingProxy, + photoLibrary, + videoAssetEventSubject, + photoAssetEventSubject + ) } init( albumName: String, _ cachingProxy: AssetCachingProxy, - _ photoLibrary: PHPhotoLibrary + _ photoLibrary: PHPhotoLibrary, + _ videoAssetEventSubject: AssetEventSubject, + _ photoAssetEventSubject: AssetEventSubject ) { self.cachingProxy = cachingProxy self.albumName = albumName self.photoLibrary = photoLibrary + self.videoAssetEventSubject = videoAssetEventSubject + self.photoAssetEventSubject = photoAssetEventSubject + + super.init() } func run(processor: T) async throws { @@ -42,6 +63,7 @@ class AespaCoreAlbumManager { } if let album { + ensureLatestFetchResults(for: album) try await processor.process(photoLibrary, album) } else { album = try AlbumImporter.getAlbum(name: albumName, in: photoLibrary) @@ -59,7 +81,8 @@ class AespaCoreAlbumManager { } if let album { - return try loader.load(photoLibrary, album) + ensureLatestFetchResults(for: album) + return try loader.loadAssets(photoLibrary, album) } else { album = try AlbumImporter.getAlbum(name: albumName, in: photoLibrary) return try await run(loader: loader) @@ -102,3 +125,76 @@ extension AespaCoreAlbumManager { } } } + +extension AespaCoreAlbumManager: PHPhotoLibraryChangeObserver { + func photoLibraryDidChange(_ changeInstance: PHChange) { + if let latestVideoFetchResult { + handleChange(changeInstance, with: latestVideoFetchResult, for: .video) + } + + if let latestPhotoFetchResult { + handleChange(changeInstance, with: latestPhotoFetchResult, for: .image) + } + } + + private func ensureLatestFetchResults(for album: PHAssetCollection) { + if latestVideoFetchResult == nil { + latestVideoFetchResult = try? AssetLoader(limit: 0, assetType: .video).laodFetchResult(photoLibrary, album) + observePhotoLibraryChanges(album: album) + } + + if latestPhotoFetchResult == nil { + latestPhotoFetchResult = try? AssetLoader(limit: 0, assetType: .image).laodFetchResult(photoLibrary, album) + observePhotoLibraryChanges(album: album) + } + } + + private func observePhotoLibraryChanges(album: PHAssetCollection) { + photoLibrary.register(self) + } + + private func handleChange( + _ changeInstance: PHChange, + with fetchResult: PHFetchResult, + for assetType: PHAssetMediaType + ) { + if let details = changeInstance.changeDetails(for: fetchResult) { + switch assetType { + case .video: + self.latestVideoFetchResult = details.fetchResultAfterChanges + handleVideoAssetChanges(details) + case .image: + self.latestPhotoFetchResult = details.fetchResultAfterChanges + handlePhotoAssetChanges(details) + default: + break + } + } + } + + private func handleVideoAssetChanges(_ details: PHFetchResultChangeDetails) { + let addedObjects = details.insertedObjects + let removedObjects = details.removedObjects + + if !addedObjects.isEmpty { + videoAssetEventSubject.send(.added(addedObjects)) + } + + if !removedObjects.isEmpty { + videoAssetEventSubject.send(.deleted(removedObjects)) + } + } + + private func handlePhotoAssetChanges(_ details: PHFetchResultChangeDetails) { + let addedObjects = details.insertedObjects + let removedObjects = details.removedObjects + + if !addedObjects.isEmpty { + photoAssetEventSubject.send(.added(addedObjects)) + } + + if !removedObjects.isEmpty { + photoAssetEventSubject.send(.deleted(removedObjects)) + } + } +} diff --git a/Sources/Aespa/Core/AespaEventManager.swift b/Sources/Aespa/Core/AespaEventManager.swift new file mode 100644 index 0000000..b93f714 --- /dev/null +++ b/Sources/Aespa/Core/AespaEventManager.swift @@ -0,0 +1,14 @@ +// +// AespaEventManager.swift +// +// +// Created by YoungBin Lee on 4/18/24. +// + +import Foundation +import Combine + +class AespaEventManager { + let videoAssetEventPublihser = PassthroughSubject() + let photoAssetEventPublihser = PassthroughSubject() +} diff --git a/Sources/Aespa/Core/Context/AespaPhotoContext.swift b/Sources/Aespa/Core/Context/AespaPhotoContext.swift index c546222..56220ea 100644 --- a/Sources/Aespa/Core/Context/AespaPhotoContext.swift +++ b/Sources/Aespa/Core/Context/AespaPhotoContext.swift @@ -106,9 +106,9 @@ extension AespaPhotoContext: PhotoContext { guard option.asset.synchronizeWithLocalAlbum else { Logger.log( message: - "'option.asset.synchronizeWithLocalAlbum' is set to false" + + "'option.asset.synchronizeWithLocalAlbum' is set to false " + "so no photos will be fetched from the local album. " + - "If you intended to fetch photos," + + "If you intended to fetch photos, " + "please ensure 'option.asset.synchronizeWithLocalAlbum' is set to true." ) return [] diff --git a/Sources/Aespa/Core/Context/AespaVideoContext.swift b/Sources/Aespa/Core/Context/AespaVideoContext.swift index 8b9d8de..40e11ab 100644 --- a/Sources/Aespa/Core/Context/AespaVideoContext.swift +++ b/Sources/Aespa/Core/Context/AespaVideoContext.swift @@ -66,13 +66,14 @@ extension AespaVideoContext: VideoContext { } public var videoFilePublisher: AnyPublisher, Never> { - videoFileBufferSubject.handleEvents(receiveOutput: { status in - if case .failure(let error) = status { - Logger.log(error: error) - } - }) - .compactMap({ $0 }) - .eraseToAnyPublisher() + videoFileBufferSubject + .handleEvents(receiveOutput: { status in + if case .failure(let error) = status { + Logger.log(error: error) + } + }) + .compactMap({ $0 }) + .eraseToAnyPublisher() } public func startRecording( @@ -146,9 +147,9 @@ extension AespaVideoContext: VideoContext { guard option.asset.synchronizeWithLocalAlbum else { Logger.log( message: - "'option.asset.synchronizeWithLocalAlbum' is set to false" + + "'option.asset.synchronizeWithLocalAlbum' is set to false " + "so no photos will be fetched from the local album. " + - "If you intended to fetch photos," + + "If you intended to fetch photos, " + "please ensure 'option.asset.synchronizeWithLocalAlbum' is set to true." ) return [] diff --git "a/Sources/Aespa/Data/Asset/Video\020Asset.swift" "b/Sources/Aespa/Data/Asset/Video\020Asset.swift" index 0f1186b..6547f73 100644 --- "a/Sources/Aespa/Data/Asset/Video\020Asset.swift" +++ "b/Sources/Aespa/Data/Asset/Video\020Asset.swift" @@ -13,7 +13,7 @@ import AVFoundation /// Struct to represent a video asset saved in the album. public struct VideoAsset { /// The associated `PHAsset` object from the Photos framework. - private let phAsset: PHAsset + public let phAsset: PHAsset /// The `AVAsset` representation of the video. public let asset: AVAsset diff --git a/Sources/Aespa/Data/Event/VideoAssetEvent.swift b/Sources/Aespa/Data/Event/VideoAssetEvent.swift new file mode 100644 index 0000000..9b2e346 --- /dev/null +++ b/Sources/Aespa/Data/Event/VideoAssetEvent.swift @@ -0,0 +1,38 @@ +// +// File.swift +// +// +// Created by YoungBin Lee on 4/18/24. +// + +import Combine +import Photos + +public typealias AssetEventSubject = PassthroughSubject + +public enum AssetEvent { + case added([PHAsset]) + case deleted([PHAsset]) +} + +public extension [VideoAsset] { + /// If you want to delete your current `VideoAsset` based on a `PHAsset`, use this method. + /// The `id` of a `VideoAsset` is the `id` of the encapsulated `PHAsset`, and this method operates based on that. + /// + /// If your goal is simply to keep `VideoAssets` up to date, consider using the `fetchVideoFiles` of `AespaSession`. + /// `fetchVideoFiles` utilizes caching internally, allowing for faster and more efficient data updates. + func remove(_ asset: PHAsset) -> [VideoAsset] { + filter { $0.phAsset.localIdentifier != asset.localIdentifier } + } +} + +public extension [PhotoAsset] { + /// If you want to delete your current `PhotoAsset` based on a `PHAsset`, use this method. + /// The `id` of a `VideoAsset` is the `id` of the encapsulated `PHAsset`, and this method operates based on that. + /// + /// If your goal is simply to keep `PhotoAsset` up to date, consider using the `fetchPhotoFiles` of `AespaSession`. + /// `fetchPhotoFiles` utilizes caching internally, allowing for faster and more efficient data updates. + func remove(_ asset: PHAsset) -> [PhotoAsset] { + filter { $0.asset.localIdentifier != asset.localIdentifier } + } +} diff --git a/Sources/Aespa/Loader/AespaLoading.swift b/Sources/Aespa/Loader/AespaLoading.swift index 7fbbaeb..852f627 100644 --- a/Sources/Aespa/Loader/AespaLoading.swift +++ b/Sources/Aespa/Loader/AespaLoading.swift @@ -10,7 +10,7 @@ import Foundation protocol AespaAssetLoading { associatedtype ReturnType - func load(_ library: Library, _ collection: Collection) throws -> ReturnType + func loadAssets(_ library: Library, _ collection: Collection) throws -> ReturnType where Library: AespaAssetLibraryRepresentable, Collection: AespaAssetCollectionRepresentable } diff --git a/Sources/Aespa/Loader/Asset/AssetLoader.swift b/Sources/Aespa/Loader/Asset/AssetLoader.swift index cbe40de..54e993c 100644 --- a/Sources/Aespa/Loader/Asset/AssetLoader.swift +++ b/Sources/Aespa/Loader/Asset/AssetLoader.swift @@ -12,12 +12,12 @@ struct AssetLoader: AespaAssetLoading { let limit: Int let assetType: PHAssetMediaType - func load< + func laodFetchResult< Library: AespaAssetLibraryRepresentable, Collection: AespaAssetCollectionRepresentable >( _ photoLibrary: Library, _ assetCollection: Collection - ) throws -> [PHAsset] { + ) throws -> PHFetchResult { let fetchOptions = PHFetchOptions() fetchOptions.fetchLimit = limit fetchOptions.predicate = NSPredicate(format: "mediaType = %d", assetType.rawValue) @@ -30,9 +30,18 @@ struct AssetLoader: AespaAssetLoading { guard let assetCollection = assetCollection as? PHAssetCollection else { fatalError("Asset collection doesn't conform to PHAssetCollection") } - + + return PHAsset.fetchAssets(in: assetCollection, options: fetchOptions) + } + + func loadAssets< + Library: AespaAssetLibraryRepresentable, Collection: AespaAssetCollectionRepresentable + >( + _ photoLibrary: Library, + _ assetCollection: Collection + ) throws -> [PHAsset] { var assets = [PHAsset]() - let assetsFetchResult = PHAsset.fetchAssets(in: assetCollection, options: fetchOptions) + let assetsFetchResult = try laodFetchResult(photoLibrary, assetCollection) assetsFetchResult.enumerateObjects { (asset, _, _) in assets.append(asset) }